Skip to main content

omni_dev/daemon/services/
worktrees.rs

1//! The worktrees daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`WorktreesRegistry`] under the
4//! daemon's lifecycle and exposes register/heartbeat/unregister/list/tree/open
5//! over the control socket, plus a tray submenu with a per-window "focus" action.
6//! The `open` op (#1266) focuses/opens an arbitrary worktree folder in VS Code
7//! through the **same** launcher path the tray uses, so a socket client (the
8//! companion's double-click) shares the tested guard and launcher resolution
9//! rather than duplicating them.
10//!
11//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
12//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
13//! ops, renders the menu/status, and drives the VS Code launcher. Like the
14//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
15//! secret persisted.
16//!
17//! The adapter also computes the **per-worktree git enrichment** (current
18//! branch, ahead/behind counts, and the parent repository a linked worktree
19//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
20//! of raw folder paths (ADR-0040). The engine stores only what the companion
21//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
22//! under the registry lock.
23//!
24//! The `tree` op (#1265) inverts the data model for the companion's tree view:
25//! from the open windows the adapter derives the **distinct repositories**, then
26//! enumerates **all** of each repo's worktrees (main working tree +
27//! [`Repository::worktrees`]), enriches each (reusing [`git_status`]), tags the
28//! GitHub identity of `origin`, and joins the open windows back on by
29//! canonicalized path. The open-window registry stays the liveness source;
30//! "is a window open on it?" becomes a per-worktree attribute. All of this is
31//! git disk I/O, so it runs on a blocking thread, never under the registry lock.
32
33mod divergence;
34mod geometry;
35mod shared_repo;
36
37use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
38use std::path::{Path, PathBuf};
39use std::process::{Command, Stdio};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, Mutex, PoisonError};
42use std::time::{Duration, Instant};
43
44use anyhow::{anyhow, bail, Context, Result};
45use chrono::{DateTime, Utc};
46
47use crate::git::remote::RemoteInfo;
48use crate::git::worktree_batch::Selection;
49use crate::git::worktree_push;
50use crate::git::worktree_rebase;
51use crate::github_rate_limit::{
52    resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
53};
54use crate::pr_status::{
55    EnqueueOutcome, PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget,
56};
57use async_trait::async_trait;
58use divergence::{AheadBehindCoordinator, Divergence};
59use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
60use serde::{Deserialize, Serialize};
61use serde_json::{json, Value};
62use tokio::sync::watch;
63use tokio::sync::Mutex as AsyncMutex;
64use tokio::task::JoinHandle;
65use tokio_util::sync::CancellationToken;
66
67use crate::daemon::service::{
68    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
69};
70use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
71
72/// The worktrees service name (the control-socket routing key).
73pub const SERVICE_NAME: &str = "worktrees";
74
75/// The tray submenu title.
76const SUBMENU_TITLE: &str = "Worktrees";
77
78/// Environment override for the VS Code launcher used by the "focus" tray
79/// action, for when the daemon runs under launchd with a minimal `PATH`.
80const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
81
82/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
83/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
84const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
85
86/// Default cadence at which the background task recomputes the tray menu snapshot
87/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
88/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
89/// the GUI thread (which would peg a core and stall shutdown — the #1186
90/// regression); the interval only governs how stale that cached branch/sync state
91/// may be when the menu is opened.
92///
93/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
94/// walk that the subscription-stream coalescing (#1303) never touched — it was
95/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
96/// menu open-latency unchanged (the cache still serves instantly).
97const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
98
99/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
100/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
101fn menu_refresh_interval() -> Duration {
102    crate::daemon::server::duration_secs_from_env(
103        ENV_MENU_REFRESH_INTERVAL,
104        DEFAULT_MENU_REFRESH_INTERVAL,
105    )
106}
107
108/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
109/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
110/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
111const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
112
113/// Default cadence for the PR badge poller while CI is in flight (#1337).
114///
115/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
116/// watching a run — which is exactly this situation. It is affordable because the
117/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
118/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
119/// while something is actually pending.
120const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
121
122/// The ceiling the poller backs off to once every badge is terminal.
123///
124/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
125/// The 30-minute figure is the cross-tool consensus for background PR polling
126/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
127/// defaults). The backoff exists for battery and wakeups rather than budget — at
128/// 1 point per poll the budget never binds.
129const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
130
131/// How long after fresh work (a push, or an added target) the poller holds its
132/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
133/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
134/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
135/// is watched more cheaply. See [`next_pr_poll_delay`].
136const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
137
138/// The ceiling the poller escalates to *while still pending* once
139/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
140/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
141/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
142/// at ~50 calls instead of ~120.
143const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
144
145/// The cadence floor the poller is held to while the shared GitHub budget is
146/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
147/// Five minutes makes the daemon's contribution to an already-strained budget
148/// negligible while still recovering promptly once the window resets — a pause in
149/// all but name. See [`budget_throttled_delay`].
150const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
151
152/// Environment override for [`pr_debounce_interval`] — the settle window the PR
153/// poller waits after the change-notify fires before snapshotting (whole seconds;
154/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
155const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
156
157/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
158///
159/// A VS Code restart unregisters then re-registers its windows one-by-one over
160/// several seconds, each bump waking the poller; a daemon restart re-registers the
161/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
162/// storm into **one** fetch on the final watch set instead of one per window.
163const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
164
165/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
166/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
167fn pr_poll_interval() -> Duration {
168    crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
169}
170
171/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
172/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
173fn pr_debounce_interval() -> Duration {
174    crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
175}
176
177/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
178/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
179/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
180const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
181
182/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
183/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
184/// the extension's transient badge fallback — from the daemon means N windows now
185/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
186const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
187
188/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
189/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
190/// `PullRequest` shape unchanged.
191const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
192
193/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
194/// (parity with the extension's `PR_LIST_LIMIT`).
195const OPEN_PR_LIST_LIMIT: &str = "100";
196
197/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
198/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
199fn open_pr_ttl() -> Duration {
200    crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
201}
202
203/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
204/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
205/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
206const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
207
208/// Default cadence for the GitHub rate-limit monitor (#1375).
209///
210/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
211/// spends nothing against any budget — so unlike the PR poller this cadence has no
212/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
213/// keeps `daemon status` current enough to catch a slow drain.
214const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
215
216/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
217/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
218fn rate_limit_poll_interval() -> Duration {
219    crate::daemon::server::duration_secs_from_env(
220        ENV_RATE_LIMIT_POLL_INTERVAL,
221        DEFAULT_RATE_LIMIT_POLL_INTERVAL,
222    )
223}
224
225/// A running background menu-refresh task and the token that stops it.
226struct RefreshTask {
227    /// Cancelled by `shutdown` to end the refresh loop.
228    token: CancellationToken,
229    /// The spawned loop, awaited on shutdown so it fully unwinds.
230    handle: JoinHandle<()>,
231}
232
233/// Whether this tick should spend a `gh` call.
234///
235/// The poller wakes far more often than it fetches — waking is a cached snapshot
236/// read, fetching is a subprocess and a network round trip. Two things justify the
237/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
238/// a push, invisible to the change-notify, so only looking finds it), or the
239/// **backoff elapsed** and it is simply time to look again.
240///
241/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
242/// worktree going away, a lease lapsing, or a TTL reap can never change any
243/// **surviving** badge, so `grew` is false and the poll skips — see
244/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
245///
246/// Pure so the policy is testable directly: from outside, the only evidence of it
247/// is *when* a subprocess runs, which a test cannot pin down without either flaking
248/// or passing for the wrong reason.
249fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
250    grew || since_last_fetch.is_none_or(|elapsed| elapsed >= backoff)
251}
252
253/// Whether `next` holds a watch the poller has not already resolved for its
254/// current upstream — an **addition** (a new (repo, branch) target) or an
255/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
256/// reports). Either warrants asking GitHub *now*.
257///
258/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
259/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
260/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
261/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
262/// exactly the cached verdict, and the badge stays correctly stale through
263/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
264/// network call (#1389, fix 3).
265///
266/// Pure so the fetch trigger is testable without driving a live subprocess.
267fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
268    next.iter().any(|w| !prev.contains(w))
269}
270
271/// The next PR-poll delay.
272///
273/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
274///   — nothing is expected to change, so this is a slow liveness heartbeat. A failed
275///   poll passes `pending: false`, so a persistent failure backs off here rather
276///   than being retried hard.
277/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
278///   [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
279///   `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
280///   run used to pin `base` for its whole duration (360 calls/hour); escalating
281///   *within* pending caps that while a verdict arriving a cadence-tick late stays
282///   invisible in the tray. `since_moved` is time since fresh work was last seen (a
283///   push or an added target), or `None` when nothing has moved yet — treated as
284///   past the fast window so a stale-from-boot pending state does not pin `base`.
285///
286/// A pure function rather than copies inline, because the cadence is only
287/// observable from outside as timing, which a test cannot assert without flaking.
288fn next_pr_poll_delay(
289    current: Duration,
290    base: Duration,
291    pending: bool,
292    since_moved: Option<Duration>,
293) -> Duration {
294    if !pending {
295        return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
296    }
297    match since_moved {
298        // Fresh work: watch it closely at `base` while it is likely to resolve.
299        Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
300        // Still pending well after the move (a long CI run, or a zombie suite):
301        // escalate so the cadence stops burning, bounded below the terminal ceiling.
302        _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
303    }
304}
305
306/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
307///
308/// The daemon is the **single** `gh` choke point for every open window, so this is
309/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
310/// any tracked resource is at/over
311/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
312/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
313/// the budget the whole machine shares — structurally, not just by convention. Below
314/// the threshold (or with no reading yet) the delay is returned unchanged.
315///
316/// Pure so the throttle is testable without a live rate-limit poll.
317fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
318    if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
319        delay.max(BUDGET_THROTTLE_INTERVAL)
320    } else {
321        delay
322    }
323}
324
325/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
326/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
327/// threshold upward since the previous reading (or is already over on the first
328/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
329/// per crossing rather than every poll while usage stays high.
330///
331/// Pure so the policy is testable without driving a live poll.
332fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
333    let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
334    // Per-resource so a *different* resource crossing (while another recovers) is
335    // still caught — `graphql` dropping below while `core` climbs over would look
336    // unchanged to a whole-snapshot `over_warn` comparison.
337    [
338        (prev.and_then(|p| p.graphql), next.graphql),
339        (prev.and_then(|p| p.core), next.core),
340        (prev.and_then(|p| p.search), next.search),
341    ]
342    .into_iter()
343    .any(|(before, after)| over(after) && !over(before))
344}
345
346/// A running background PR-badge poll task and the token that stops it.
347struct PollerTask {
348    /// Cancelled by `shutdown` to end the poll loop.
349    token: CancellationToken,
350    /// The spawned loop, awaited on shutdown so it fully unwinds.
351    handle: JoinHandle<()>,
352}
353
354/// One thing the PR poller watches: a badge target and the commit its upstream
355/// points at.
356///
357/// The upstream OID is what makes a **push** observable to the poller. A window
358/// opening bumps the registry's change-notify, but nothing notifies the daemon
359/// when you push — so the poller compares this against the previous tick's and
360/// treats an added target or a moved upstream as "go and ask now" (see
361/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
362/// very thing that starts the CI run a badge reports, so the upstream must be here.
363///
364/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
365/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
366/// call, and the badge stays correctly stale through
367/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
368/// comparison, no network — until the branch is actually pushed. Equality is thus
369/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
370#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
371struct PrWatch {
372    /// The (repo, branch) to resolve a badge for.
373    target: PrTarget,
374    /// That branch's upstream tip, or `None` when it tracks no upstream.
375    upstream_sha: Option<String>,
376}
377
378/// Extracts what the poller watches — the badge targets and their local heads and
379/// upstream tips — from a `tree` snapshot.
380///
381/// Reading them back off the snapshot — rather than walking git again — means the
382/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
383/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
384/// out of its way to remove. Only GitHub repos with a branch contribute; the result
385/// is sorted and deduped so N worktrees of one repo on one branch ask once.
386fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
387    let mut out = Vec::new();
388    for repo in snapshot
389        .get("repos")
390        .and_then(Value::as_array)
391        .into_iter()
392        .flatten()
393    {
394        // The zero-`gh` guarantee (#1376): a repo the user has not enabled
395        // contributes no watch, so the poll's `gh api graphql` never mentions it.
396        // The snapshot this reads is already `stamp_polling`-stamped, so this one
397        // check is the single filter point — default-off means an absent flag skips.
398        if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
399            continue;
400        }
401        let Some(github) = repo.get("github") else {
402            continue;
403        };
404        let (Some(owner), Some(name)) = (
405            github.get("owner").and_then(Value::as_str),
406            github.get("name").and_then(Value::as_str),
407        ) else {
408            continue;
409        };
410        for wt in repo
411            .get("worktrees")
412            .and_then(Value::as_array)
413            .into_iter()
414            .flatten()
415        {
416            if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
417                out.push(PrWatch {
418                    upstream_sha: wt
419                        .get("upstream_sha")
420                        .and_then(Value::as_str)
421                        .map(str::to_string),
422                    target: PrTarget {
423                        owner: owner.to_string(),
424                        name: name.to_string(),
425                        branch: branch.to_string(),
426                    },
427                });
428            }
429        }
430    }
431    out.sort();
432    out.dedup();
433    out
434}
435
436/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
437/// without the heads.
438#[cfg(test)]
439fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
440    pr_watch_from_snapshot(snapshot)
441        .into_iter()
442        .map(|w| w.target)
443        .collect()
444}
445
446/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
447pub struct WorktreesService {
448    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
449    /// the background menu-refresh task can read it off the main thread.
450    registry: Arc<WorktreesRegistry>,
451    /// The most recent tray menu snapshot, recomputed off the main thread by
452    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
453    /// of this so it never blocks on git enrichment. `None` until the first
454    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
455    /// which case `menu()` falls back to a one-off inline compute.
456    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
457    /// The background refresh task, once started (`None` in tests / no runtime).
458    refresh: Mutex<Option<RefreshTask>>,
459    /// PR badges resolved by the background poller and read by the tree snapshot
460    /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
461    /// share the one cache. Empty until the first poll lands — and always empty
462    /// when no poller runs (unit tests), in which case the tree simply carries no
463    /// `pr` field, exactly as a pre-#1337 daemon did.
464    pr_cache: Arc<PrStatusCache>,
465    /// The background PR-badge poll task, once started (`None` in tests / no
466    /// runtime).
467    poller: Mutex<Option<PollerTask>>,
468    /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
469    /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
470    /// poll task, the tray refresh, and the daemon's registry share the one cache.
471    /// Empty until the first poll lands; the daemon hands a clone to the registry
472    /// so `status` can report machine-wide GitHub budget usage.
473    ///
474    /// [`rate-limit poller`]: Self::start_rate_limit_poller
475    rate_limit_cache: Arc<RateLimitCache>,
476    /// The background rate-limit poll task, once started (`None` in tests / no
477    /// runtime).
478    rate_limit_poller: Mutex<Option<PollerTask>>,
479    /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
480    /// through, so N open windows perform **one** `build_tree` per tick instead
481    /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
482    /// one cache. The one-shot `tree` op deliberately bypasses it and computes
483    /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
484    tree_cache: Arc<TreeSnapshotCache>,
485    /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
486    ///
487    /// The extension fans a multi-select delete out into one `close` op per
488    /// target, so two executes can reach the prune at once. Their heartbeat waits
489    /// overlap freely — that is the point — but the prunes themselves should not:
490    /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
491    /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
492    /// read a directory a sibling is midway through removing from, and `git2`
493    /// promises nothing about that. Precautionary rather than a fix for an
494    /// observed corruption — the window is narrow enough that it has not been
495    /// reproduced — but serializing costs nothing measurable (the prune is a
496    /// directory delete; the wait it follows is seconds) and keeps the fan-out
497    /// safe at the source rather than relying on every caller to stay sequential.
498    ///
499    /// A `tokio` mutex rather than a `std` one: it is held across the
500    /// `spawn_blocking` join, which is an `.await`.
501    prune_lock: tokio::sync::Mutex<()>,
502    /// Shares and bounds the `ahead-behind` op's git work across concurrent
503    /// requests (#2111): N windows asking about the same worktree cost one
504    /// computation, and only a few run at once. See [`divergence`].
505    ahead_behind: AheadBehindCoordinator,
506    /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
507    /// choice survives a daemon restart. `None` disables persistence entirely —
508    /// the default from [`new`](Self::new), which keeps the bare service cheap
509    /// and I/O-free for unit tests; the daemon wires it via
510    /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
511    /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
512    /// briefly and never held across an `.await`.
513    polling_prefs_path: Mutex<Option<PathBuf>>,
514    /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
515    /// survive a daemon restart and the poller can skip its immediate re-poll when
516    /// they are still fresh. `None` disables persistence — the default from
517    /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
518    /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
519    /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
520    pr_cache_path: Mutex<Option<PathBuf>>,
521    /// The warm-start state restored from the persisted cache (#1389, fix 4),
522    /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
523    /// spawns. `None` on a cold start (no file, or persistence disabled), in which
524    /// case the poller does its normal first fetch.
525    pr_warm_start: Mutex<Option<PrWarmStart>>,
526    /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
527    /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
528    /// dedupe to one counted `gh` per repo instead of one per window. Behind an
529    /// `Arc` for parity with the other caches.
530    open_pr_cache: Arc<OpenPrCache>,
531    /// Where the windows the **last** `reposition` moved were sitting beforehand,
532    /// so `reposition-undo` can put them back (#1407).
533    ///
534    /// Exactly one level of undo, deliberately: the affordance exists because
535    /// repositioning is otherwise irreversible (the previous layout is simply
536    /// gone), and "undo the thing I just did" is the whole of what that needs. A
537    /// deeper stack would raise questions — what does undoing an older batch mean
538    /// once a newer one has moved the same window? — that a one-level store cannot
539    /// pose. Each successful `reposition` replaces it; each `reposition-undo`
540    /// consumes it, so an undo cannot be replayed.
541    ///
542    /// In-memory only, like every other piece of registry state: a daemon restart
543    /// drops it, and the user is simply left with the layout they have. Behind its
544    /// **own** `std::Mutex`, taken independently of the registry's (neither nests)
545    /// and never held across an `.await`.
546    reposition_undo: Mutex<Vec<(String, geometry::Frame)>>,
547    /// Serializes the `rebase` op's phase-2 execute across concurrent requests
548    /// (#1415) — the [`prune_lock`](Self::prune_lock) precedent, one op over.
549    ///
550    /// Within a batch the engine already rebases sequentially, on purpose: linked
551    /// worktrees share one object database, and `git rebase` writes refs and
552    /// packs into it. Two *concurrent requests* would defeat that, so the lock
553    /// restores it globally. It also means a second click cannot start a rebase of
554    /// a worktree the first is still mid-way through — the `operation`-in-progress
555    /// classifier only sees state that is already on disk.
556    ///
557    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
558    rebase_lock: tokio::sync::Mutex<()>,
559    /// Serializes the `push` op's phase-2 execute across concurrent requests
560    /// (#1443) — the [`rebase_lock`](Self::rebase_lock) twin.
561    ///
562    /// A successful push writes `refs/remotes/<remote>/<branch>` into the object
563    /// database and ref store that every linked worktree of the repository shares,
564    /// so concurrent batches would race on it. Taking the lock **before** the
565    /// re-plan (not merely around the execute) is what keeps the classification the
566    /// engine acts on from being invalidated by another batch's push mid-flight —
567    /// which for a lease is not benign: a plan taken outside the lock could still
568    /// say `would-force` against a tracking ref the other run has since advanced.
569    ///
570    /// Deliberately **separate** from `rebase_lock` rather than shared: a rebase and
571    /// a push touch different refs (`refs/heads/*` vs `refs/remotes/*`) and there is
572    /// no reason a push should wait behind an unrelated repository's rebase.
573    ///
574    /// A `tokio` mutex, since it is held across the `spawn_blocking` join.
575    push_lock: tokio::sync::Mutex<()>,
576}
577
578impl WorktreesService {
579    /// Creates the service with an empty registry. Cheap — no I/O and no task;
580    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
581    /// off-thread menu caching, while tests use the bare service (menu computed
582    /// inline on demand).
583    #[must_use]
584    pub fn new() -> Self {
585        let registry = Arc::new(WorktreesRegistry::new());
586        let pr_cache = Arc::new(PrStatusCache::new());
587        Self {
588            registry: registry.clone(),
589            menu_cache: Arc::new(Mutex::new(None)),
590            refresh: Mutex::new(None),
591            pr_cache: pr_cache.clone(),
592            poller: Mutex::new(None),
593            rate_limit_cache: Arc::new(RateLimitCache::new()),
594            rate_limit_poller: Mutex::new(None),
595            tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
596            prune_lock: tokio::sync::Mutex::new(()),
597            ahead_behind: AheadBehindCoordinator::new(),
598            polling_prefs_path: Mutex::new(None),
599            pr_cache_path: Mutex::new(None),
600            pr_warm_start: Mutex::new(None),
601            open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
602            reposition_undo: Mutex::new(Vec::new()),
603            rebase_lock: tokio::sync::Mutex::new(()),
604            push_lock: tokio::sync::Mutex::new(()),
605        }
606    }
607
608    /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
609    /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
610    /// back to it (#1376). Called once by the daemon at startup, before any window
611    /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
612    /// bump. Best-effort throughout: a missing file is the first-run default (no
613    /// repos enabled), and a corrupt/unreadable one is logged and treated as
614    /// empty rather than wedging the service — the user simply re-enables. The
615    /// path is stored regardless, so the next change rewrites a clean file.
616    pub fn load_polling_prefs(&self, path: PathBuf) {
617        match std::fs::read(&path) {
618            Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
619                Ok(prefs) => self
620                    .registry
621                    .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
622                Err(err) => tracing::warn!(
623                    "ignoring unreadable worktrees polling prefs at {}: {err:#}",
624                    path.display()
625                ),
626            },
627            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
628            Err(err) => tracing::warn!(
629                "could not read worktrees polling prefs at {}: {err:#}",
630                path.display()
631            ),
632        }
633        *self
634            .polling_prefs_path
635            .lock()
636            .unwrap_or_else(PoisonError::into_inner) = Some(path);
637    }
638
639    /// Writes the current enable set to the `0600` prefs file, if persistence is
640    /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
641    /// Best-effort: a write failure is logged at WARN and swallowed, since the
642    /// in-memory set is authoritative for the running daemon — the user's toggle
643    /// still took effect, it just would not survive a restart. A no-op (returns
644    /// early) in unit tests, which never configure a path.
645    fn persist_polling_prefs(&self) {
646        let Some(path) = self
647            .polling_prefs_path
648            .lock()
649            .unwrap_or_else(PoisonError::into_inner)
650            .clone()
651        else {
652            return;
653        };
654        let prefs = PollingPrefs {
655            enabled: self
656                .registry
657                .polling_snapshot()
658                .into_iter()
659                .map(|(repo, expires_at)| PollingLease { repo, expires_at })
660                .collect(),
661        };
662        if let Err(err) = write_polling_prefs(&path, &prefs) {
663            tracing::warn!(
664                "could not persist worktrees polling prefs to {}: {err:#}",
665                path.display()
666            );
667        }
668    }
669
670    /// Seeds the resolved PR-badge cache from the persisted `0600` file and
671    /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
672    /// once by the daemon at startup, before any window subscribes and before the
673    /// poller spawns, so restored badges render on the first tree snapshot and the
674    /// poller can skip its immediate re-poll for verdicts still fresh.
675    ///
676    /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
677    /// contract): a missing file is the cold-start default, and a corrupt/unreadable
678    /// one is logged and treated as empty — the poller simply re-resolves. The path
679    /// is stored regardless, so the next poll rewrites a clean file. Restores both
680    /// the badges (into [`pr_cache`](Self::pr_cache)) and the
681    /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
682    pub fn load_pr_cache(&self, path: PathBuf) {
683        match std::fs::read(&path) {
684            Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
685                Ok(prefs) => {
686                    self.pr_cache.seed(
687                        prefs
688                            .entries
689                            .into_iter()
690                            .map(|e| (e.target, e.resolution.into_resolution())),
691                    );
692                    // A warm start needs both a watch set to compare against and a
693                    // poll time to age it; without `polled_at` the file is too old a
694                    // shape to trust, so treat it as a cold start (badges still
695                    // render, the poller just re-polls immediately).
696                    if let Some(polled_at) = prefs.polled_at {
697                        let watched = prefs
698                            .watched
699                            .into_iter()
700                            .map(|w| PrWatch {
701                                target: w.target,
702                                upstream_sha: w.upstream_sha,
703                            })
704                            .collect();
705                        *self
706                            .pr_warm_start
707                            .lock()
708                            .unwrap_or_else(PoisonError::into_inner) =
709                            Some(PrWarmStart { watched, polled_at });
710                    }
711                }
712                // Bind the path so it is formatted whenever the branch runs — not
713                // only when a WARN subscriber is installed — so coverage sees it
714                // (the `let summary = …` pattern the rate-limit warn uses).
715                Err(err) => {
716                    let at = path.display();
717                    tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
718                }
719            },
720            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
721            Err(err) => {
722                let at = path.display();
723                tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
724            }
725        }
726        *self
727            .pr_cache_path
728            .lock()
729            .unwrap_or_else(PoisonError::into_inner) = Some(path);
730    }
731
732    /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
733    /// served from the shared TTL cache when fresh, else **one** counted `gh pr
734    /// list`. The `gh` runs on a blocking thread (never an async worker), routed
735    /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
736    /// point so the call is still counted exactly once — the constraint the whole
737    /// of #1389 preserves. The result is forwarded to the extension verbatim.
738    async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
739        // The `gh` binary is resolved once here (the env read is process-stable),
740        // then handed to the seam below — the poller's "bin as a param" pattern, so
741        // a test injects a stub without mutating the process environment (#1030).
742        self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
743            .await
744    }
745
746    /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
747    /// the cache against a stub without touching the environment.
748    async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
749        let key = format!("{owner}/{name}");
750        if let Some(prs) = self.open_pr_cache.fresh(&key) {
751            return Ok(prs);
752        }
753        let slug = key.clone();
754        let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
755            .await
756            .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
757        self.open_pr_cache.store(key, prs.clone());
758        Ok(prs)
759    }
760
761    /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
762    /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
763    /// for the built-in `status` op to read.
764    #[must_use]
765    pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
766        self.rate_limit_cache.clone()
767    }
768
769    /// Starts the background task that recomputes the tray menu snapshot every
770    /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
771    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
772    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
773    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
774    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
775    /// keep computing the menu inline.
776    pub fn start_menu_refresh(&self) {
777        if tokio::runtime::Handle::try_current().is_err() {
778            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
779            return;
780        }
781        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
782        if guard.is_some() {
783            return;
784        }
785        let token = CancellationToken::new();
786        let loop_token = token.clone();
787        let registry = self.registry.clone();
788        let cache = self.menu_cache.clone();
789        let rate_limit_cache = self.rate_limit_cache.clone();
790        // Resolved once at spawn: the interval is process-stable env config, and
791        // re-reading it every loop would be wasted work.
792        let interval = menu_refresh_interval();
793        let handle = tokio::spawn(async move {
794            loop {
795                // Snapshot the registry (a cheap lock), then build the menu —
796                // which opens repos and parses git config — on a blocking thread,
797                // never on this async worker or the tray's main thread.
798                let entries = registry.list();
799                // The rate-limit reading (a cheap lock, `Copy`) prepends a status
800                // line; read it here and hand it to the blocking build (#1375).
801                let rate_limit = rate_limit_cache.get();
802                if let Ok(items) = tokio::task::spawn_blocking(move || {
803                    menu_items_for(&entries, rate_limit.as_ref())
804                })
805                .await
806                {
807                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
808                }
809                tokio::select! {
810                    () = loop_token.cancelled() => break,
811                    () = tokio::time::sleep(interval) => {}
812                }
813            }
814        });
815        *guard = Some(RefreshTask { token, handle });
816    }
817
818    /// Starts the background task that keeps PR check badges fresh (#1337).
819    ///
820    /// This is the half of the badge nothing else can do. Badges used to be
821    /// resolved extension-side on repo-expand, so they were recomputed only when a
822    /// repo node's children were rebuilt — and the streamed snapshot carries
823    /// worktree topology, not CI. While CI ran and no window opened or closed,
824    /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
825    ///
826    /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
827    /// (cost 1, independent of repo/worktree/window count), writes the cache the
828    /// tree snapshot reads, and bumps the registry's change-notify **only when a
829    /// verdict actually moved** — so the server's diff pushes to every open window
830    /// exactly when CI state changes, and never otherwise.
831    ///
832    /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
833    /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
834    /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
835    /// terminal; it polls nothing at all while no window is registered.
836    ///
837    /// It spends a `gh` call only when the watch set **grows** — a target added or
838    /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
839    /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
840    /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
841    /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
842    /// 4), and the cadence is capped when the shared GitHub budget is strained
843    /// (#1389 fix 6).
844    ///
845    /// Idempotent, and a no-op outside a tokio runtime (mirroring
846    /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
847    /// heartbeat), so unit tests build a bare service that never spawns `gh`.
848    pub fn start_pr_poller(&self) {
849        // Resolved once at spawn: process-stable env config (the menu-refresh
850        // precedent), never re-read per poll.
851        self.start_pr_poller_with(
852            pr_poll_interval(),
853            pr_debounce_interval(),
854            crate::pr_status::resolve_gh_binary(),
855        );
856    }
857
858    /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
859    /// debounce settle window, and `gh` binary, so tests drive the loop at
860    /// millisecond speed against a stub **without mutating the process
861    /// environment** — one global env var cannot serve two parallel tests pointing
862    /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
863    /// Snowflake heartbeat's "interval via config, not env" rule.
864    ///
865    /// Reads the rate-limit cache, the persistence path, and the warm-start state
866    /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
867    /// the original two-cadence seam.
868    fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
869        if tokio::runtime::Handle::try_current().is_err() {
870            tracing::debug!("no tokio runtime; worktrees PR poller not started");
871            return;
872        }
873        let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
874        if guard.is_some() {
875            return;
876        }
877        let token = CancellationToken::new();
878        let loop_token = token.clone();
879        let registry = self.registry.clone();
880        let tree_cache = self.tree_cache.clone();
881        let pr_cache = self.pr_cache.clone();
882        // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
883        // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
884        // the loop once and must not be reused by a later restart of the poller.
885        let rate_limit_cache = self.rate_limit_cache.clone();
886        let pr_cache_path = self
887            .pr_cache_path
888            .lock()
889            .unwrap_or_else(PoisonError::into_inner)
890            .clone();
891        let warm_start = self
892            .pr_warm_start
893            .lock()
894            .unwrap_or_else(PoisonError::into_inner)
895            .take();
896        // Captured here, before the task's first sleep, so a window that registers
897        // while the loop is starting still wakes it rather than being missed.
898        let mut changes = self.registry.subscribe_changes();
899        let handle = tokio::spawn(async move {
900            // Two independent cadences. The loop *wakes* every `base` — cheap: a read
901            // of the coalescing snapshot cache, no subprocess, no network. It only
902            // *asks GitHub* when there is reason to: the watch set grew (a target
903            // added, or an upstream pushed), or the backoff has elapsed.
904            //
905            // They have to be separate because the two things that should trigger a
906            // fetch arrive by different routes. A window opening bumps the registry's
907            // change-notify, but **a push does not** — nothing in the daemon is
908            // notified when you `git push`. The only way to notice is to look, so the
909            // loop looks often and cheaply, and pays only when something grew.
910            let mut backoff = base;
911            // Warm start (#1389, fix 4): resume what the previous daemon last
912            // resolved and when, so a restart within the backoff window skips the
913            // immediate re-poll for verdicts already restored into `pr_cache`.
914            // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
915            // reboot (monotonic epoch reset) or a future timestamp collapses to
916            // "never polled", which just re-polls — the safe direction.
917            let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
918                match warm_start {
919                    Some(ws) => {
920                        let elapsed = (Utc::now() - ws.polled_at)
921                            .to_std()
922                            .unwrap_or(Duration::ZERO);
923                        (Some(ws.watched), Instant::now().checked_sub(elapsed))
924                    }
925                    None => (None, None),
926                };
927            // When fresh work (a push or an added target) was last seen, so the
928            // pending cadence can escalate once it goes quiet (#1389, fix 5).
929            let mut moved_at: Option<Instant> = None;
930            'poll: loop {
931                // Wait first: at startup no window has registered yet, and the
932                // first snapshot would be empty anyway.
933                tokio::select! {
934                    () = loop_token.cancelled() => break,
935                    () = tokio::time::sleep(base) => {}
936                    // A window opened or closed — look now rather than at the next
937                    // tick, but debounce first.
938                    result = changes.changed() => {
939                        // Unreachable today: this task owns an `Arc` of the registry
940                        // that holds the sender, so it cannot be dropped while we are
941                        // here. Kept anyway because the alternative is worse — a
942                        // closed channel makes `changed()` return `Ready` forever, so
943                        // ignoring the error would spin this loop at full speed,
944                        // re-snapshotting and re-running `gh` every iteration.
945                        if result.is_err() {
946                            break;
947                        }
948                        // Debounce (#1389, fix 2): a VS Code restart unregisters then
949                        // re-registers its windows one-by-one over several seconds,
950                        // each bump waking us; a daemon restart re-registers the same
951                        // way. Wait for `debounce` of quiet before snapshotting so the
952                        // whole storm collapses to **one** fetch on the final watch
953                        // set. Bounded by an overall deadline so a steady drip of
954                        // changes cannot postpone the poll forever.
955                        let overall_deadline = Instant::now() + debounce.saturating_mul(4);
956                        loop {
957                            tokio::select! {
958                                () = loop_token.cancelled() => break 'poll,
959                                () = tokio::time::sleep(debounce) => break,
960                                r = changes.changed() => {
961                                    if r.is_err() {
962                                        break 'poll;
963                                    }
964                                    if Instant::now() >= overall_deadline {
965                                        break;
966                                    }
967                                }
968                            }
969                        }
970                    }
971                }
972                // Off the coalescing snapshot cache, so this reuses the tick's
973                // `build_tree` rather than walking git a second time.
974                let snapshot = tree_cache.snapshot().await;
975                let watch = pr_watch_from_snapshot(&snapshot);
976                if watch.is_empty() {
977                    // No windows, or nothing on GitHub: ask nothing, and forget any
978                    // backoff so the next tree starts fresh. But **keep** `watched`
979                    // (#1389, fix 4): a VS Code restart momentarily empties the watch
980                    // mid-storm, and nulling it here would make the re-registered set
981                    // look brand-new and re-fetch. A genuinely gone tree simply has
982                    // nothing to compare against on the next non-empty tick.
983                    backoff = base;
984                    last_poll = None;
985                    moved_at = None;
986                    continue;
987                }
988                // Did the watched set **grow** (an addition, or an upstream pushed)?
989                // A pure removal is not a reason to fetch (#1389, fix 1); a local
990                // commit is not either (#1389, fix 3, `PrWatch` carries no head).
991                let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
992                // Prune verdicts for targets that vanished, so a closed worktree's
993                // badge does not linger in the cache (#1389, fix 1) — local, no
994                // network, and correct whether or not this tick goes on to fetch.
995                let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
996                pr_cache.retain_targets(&keep);
997                // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
998                // choke point, so throttling here is the one place a machine-wide cap
999                // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
1000                // immediate `grew`, so no runaway in this class can drain the shared
1001                // budget — structurally, not by convention.
1002                let rate_limit = rate_limit_cache.get();
1003                let over_budget = rate_limit.is_some_and(|s| s.over_warn());
1004                let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
1005                let trigger = grew && !over_budget;
1006                if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
1007                    // Not fetching this tick. Advance `watched` only for a pure shrink
1008                    // or a quiet identical tick — an addition/push (`grew`) must stay
1009                    // unresolved so it is still fetched once the cadence or budget
1010                    // allows, rather than being silently consumed here.
1011                    if !grew {
1012                        watched = Some(watch);
1013                    }
1014                    continue;
1015                }
1016                if grew {
1017                    // Fresh work: watch it closely and restart the escalation clock
1018                    // rather than serving out a backoff earned while it was quiet.
1019                    backoff = base;
1020                    moved_at = Some(Instant::now());
1021                }
1022                let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
1023                // `gh` is a blocking subprocess: never on an async worker. A join
1024                // failure (the task panicked, or the runtime is going down) folds
1025                // into the same error channel as a `gh` failure — both mean "no
1026                // badges this round", and neither deserves its own handling.
1027                let bin = gh_bin.clone();
1028                let resolved = tokio::task::spawn_blocking(move || {
1029                    crate::pr_status::resolve_with_budget(&bin, &targets)
1030                })
1031                .await
1032                .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
1033                // Best-effort decoration: a missing/unauthenticated `gh`, a network
1034                // blip, or a rate limit must never sink the tree. A failed poll
1035                // leaves the last good resolutions in place — badges *and* explicit
1036                // negatives, and it mints no new negatives (#1370) — rather than
1037                // blanking every row, and is not "pending", so it backs off rather
1038                // than hammers.
1039                let (pending, resolved_ok) = match resolved {
1040                    Ok((resolutions, budget)) => {
1041                        // Fold the free budget reading this poll carried into the
1042                        // shared cache (#1389, fix 8): the graphql figure stays fresh
1043                        // whenever polling is active, which lets the standalone
1044                        // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
1045                        // reveals the real per-call point price.
1046                        if let Some(b) = budget {
1047                            tracing::debug!(
1048                                "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
1049                                b.cost,
1050                                b.used,
1051                                b.limit,
1052                                b.remaining
1053                            );
1054                            rate_limit_cache.observe_graphql(RateLimitResource::new(
1055                                b.used,
1056                                b.limit,
1057                                b.remaining,
1058                                b.reset,
1059                            ));
1060                        }
1061                        // Bump only on a real change, or the server's diff-and-drop
1062                        // is defeated and every window re-renders on every poll.
1063                        if pr_cache.replace(resolutions) {
1064                            registry.bump();
1065                        }
1066                        (pr_cache.any_pending(), true)
1067                    }
1068                    Err(err) => {
1069                        tracing::debug!("PR badge poll failed: {err:#}");
1070                        (false, false)
1071                    }
1072                };
1073                last_poll = Some(Instant::now());
1074                // Record what this verdict was about, so the *next* tick can tell a
1075                // genuine change from a quiet tree.
1076                watched = Some(watch);
1077                let since_moved = moved_at.map(|at| at.elapsed());
1078                backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1079                // Persist the fresh verdicts (#1389, fix 4) so the next restart
1080                // serves them and can skip its immediate re-poll. Only on a real
1081                // resolution — a failed poll must not advance the persisted poll time
1082                // past the last *good* one. Best-effort; a write failure costs at
1083                // most one extra poll after the next restart.
1084                if resolved_ok {
1085                    if let Some(path) = &pr_cache_path {
1086                        persist_pr_cache(
1087                            path,
1088                            &pr_cache,
1089                            watched.as_deref().unwrap_or(&[]),
1090                            Utc::now(),
1091                        );
1092                    }
1093                }
1094            }
1095        });
1096        *guard = Some(PollerTask { token, handle });
1097    }
1098
1099    /// Starts the background task that keeps the GitHub API rate-limit reading
1100    /// fresh (#1375).
1101    ///
1102    /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1103    /// budget as every other tool sharing the user's token; when that drains, `gh`
1104    /// rate-limits machine-wide with no warning until commands start failing. This
1105    /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1106    /// project verified) as **exempt**, spending nothing against any budget — so
1107    /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1108    /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1109    ///
1110    /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1111    /// backoff and no window-gating, because the endpoint is free and a current
1112    /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1113    /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1114    /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1115    /// bare service that never spawns `gh`.
1116    pub fn start_rate_limit_poller(&self) {
1117        // Both resolved once at spawn: process-stable env config, never re-read per
1118        // poll (the PR-poller precedent).
1119        self.start_rate_limit_poller_with(
1120            rate_limit_poll_interval(),
1121            crate::pr_status::resolve_gh_binary(),
1122        );
1123    }
1124
1125    /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1126    /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1127    /// against a stub **without mutating the process environment** (the
1128    /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1129    fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1130        if tokio::runtime::Handle::try_current().is_err() {
1131            tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1132            return;
1133        }
1134        let mut guard = self
1135            .rate_limit_poller
1136            .lock()
1137            .unwrap_or_else(PoisonError::into_inner);
1138        if guard.is_some() {
1139            return;
1140        }
1141        let token = CancellationToken::new();
1142        let loop_token = token.clone();
1143        let cache = self.rate_limit_cache.clone();
1144        let registry = self.registry.clone();
1145        let handle = tokio::spawn(async move {
1146            // Remembers the previous reading so a WARN fires only on the *rising*
1147            // edge across the threshold, not every poll while usage stays high.
1148            let mut prev: Option<RateLimitSnapshot> = None;
1149            loop {
1150                // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1151                // no window registered and no polling lease active — has nothing to
1152                // watch, so it spends no `/rate_limit` subprocess and lets the last
1153                // reading stand. The read is free against the budget, but the
1154                // wakeups are not; and while polling *is* active the graphql figure
1155                // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1156                // standalone poll is only topping up `core`/`search`.
1157                if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1158                    // Poll first, so `status` has a reading soon after a window
1159                    // appears rather than one interval later. `gh` is a blocking
1160                    // subprocess: never on an async worker. A join failure folds into
1161                    // the same channel as a `gh` failure — both mean "no fresh
1162                    // reading this round".
1163                    let bin = gh_bin.clone();
1164                    let resolved =
1165                        tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1166                            .await
1167                            .unwrap_or_else(|err| {
1168                                Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1169                            });
1170                    match resolved {
1171                        Ok(snap) => {
1172                            if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1173                                // Bound to a local (rather than inlined into the
1174                                // macro) so it is computed whenever the branch is
1175                                // taken, not only when a WARN-level subscriber is
1176                                // installed — `tracing` skips evaluating macro args
1177                                // otherwise.
1178                                let summary = snap.summary_line();
1179                                tracing::warn!(
1180                                    "GitHub API rate limit high: {summary} (querying \
1181                                     /rate_limit is free; the daemon's gh usage is not)"
1182                                );
1183                            }
1184                            // Update the cache only; deliberately no `registry.bump()`
1185                            // — the rate limit is not tree topology, and bumping would
1186                            // re-push an unchanged tree to every window. The tray
1187                            // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1188                            cache.replace(snap);
1189                            prev = Some(snap);
1190                        }
1191                        // Best-effort decoration: a missing/unauthenticated `gh` or a
1192                        // network blip leaves the last good reading in place rather
1193                        // than blanking the line, and never affects the budget (the
1194                        // read is free).
1195                        Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1196                    }
1197                }
1198                tokio::select! {
1199                    () = loop_token.cancelled() => break,
1200                    () = tokio::time::sleep(interval) => {}
1201                }
1202            }
1203        });
1204        *guard = Some(PollerTask { token, handle });
1205    }
1206
1207    /// Handles the `close` op: close a worktree's window and, for a **linked**
1208    /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1209    ///
1210    /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1211    ///   [`git_safety`] check returning the risks of deleting, so the extension can
1212    ///   show a modal confirm only when something would actually be lost.
1213    /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1214    ///   the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1215    ///   worktree. The main working tree is refused defensively.
1216    ///
1217    /// Cross-window signalling (another window has the target open) is a
1218    /// fast-follow: this core handles the **no-window** and **self-close**
1219    /// (`requester_key == target_key`) cases, and errors clearly when another
1220    /// window owns the target so the destructive path is never taken blind.
1221    async fn close(&self, req: CloseRequest) -> Result<Value> {
1222        // Which live windows currently have the target open. The canonical-path
1223        // compare is disk I/O, so run it (with the safety check below) on a
1224        // blocking thread, never under the registry lock or on the async worker.
1225        let entries = self.registry.list();
1226        let scan_path = req.path.clone();
1227        let open_windows =
1228            tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1229                .await
1230                .unwrap_or_default();
1231        let open = !open_windows.is_empty();
1232        let window_key = open_windows.first().map(|(k, _)| k.clone());
1233        let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1234
1235        // Phase 1: the safety check runs only for a delete request awaiting
1236        // confirmation. A "Close Window" (remove:false) never inspects git and
1237        // has nothing to confirm, so it skips straight to execute.
1238        if req.remove && !req.confirmed {
1239            let path = req.path.clone();
1240            let git = tokio::task::spawn_blocking(move || git_safety(&path))
1241                .await
1242                .map_err(|e| anyhow!("safety check task panicked: {e}"))
1243                .and_then(|inner| inner)
1244                .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1245            // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1246            // (#1364): the target, the owning window key (if any), whether a
1247            // window has it open, and the deletability verdict — with the
1248            // blocking risk kinds that force a confirm dialog, so a later "why did
1249            // the close prompt/refuse?" is answerable from the log alone.
1250            log_safety_check(&req.path, window_key.as_deref(), &git, open);
1251            return Ok(serde_json::to_value(SafetyReport {
1252                removable: git.removable,
1253                is_main: git.is_main,
1254                open,
1255                window_key,
1256                window_folder_count,
1257                risks: git.risks,
1258                info: git.info,
1259            })
1260            .unwrap_or_else(|_| json!({})));
1261        }
1262
1263        // Phase 2: execute. Signal every owning window *other than the
1264        // requester* (which closes itself on our `ok:true` reply, avoiding the
1265        // ext-host-dies-mid-op race) and wait for each to unregister before
1266        // touching the worktree. The directive reaches a cross-window target via
1267        // its heartbeat reply — the only channel the daemon has to a window it
1268        // can reply to but never call.
1269        let others: Vec<String> = open_windows
1270            .iter()
1271            .map(|(k, _)| k.clone())
1272            .filter(|k| req.requester_key.as_deref() != Some(k))
1273            .collect();
1274        // A self-close is the requester closing a window it owns: it never rides
1275        // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1276        // (#1364) so the execute's routing decision is auditable before the wait,
1277        // even if that wait then hangs or times out.
1278        let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1279        log_executing(
1280            &req.path,
1281            req.requester_key.as_deref(),
1282            req.remove,
1283            self_close,
1284            others.len(),
1285        );
1286        if !others.is_empty() {
1287            if let Err(err) = self
1288                .signal_and_await_close(
1289                    &req.path,
1290                    req.requester_key.as_deref(),
1291                    &others,
1292                    CLOSE_WAIT_TIMEOUT,
1293                    CLOSE_WAIT_POLL,
1294                )
1295                .await
1296            {
1297                log_close_abort(&req.path, &err);
1298                return Err(err);
1299            }
1300        }
1301
1302        if req.remove {
1303            let path = req.path.clone();
1304            // The live window set, so a working-tree-gone-but-admin-present orphan
1305            // can find its owning main repo to prune (#1403); unused on the common
1306            // path where the checkout still exists.
1307            let entries = self.registry.list();
1308            // Taken *after* the wait above, so concurrent executes still overlap
1309            // their heartbeat waits (#1359) and only the prune itself serializes.
1310            // Load-bearing placement, not incidental: hoisting this above
1311            // `await_windows_closed` would restack the waits and undo the whole
1312            // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1313            let _guard = self.prune_lock.lock().await;
1314            let removed = tokio::task::spawn_blocking(move || remove_worktree(&path, &entries))
1315                .await
1316                .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1317                .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1318            // The audit line + Result→reply mapping lives in a sync helper so the
1319            // destructive outcome is unit-testable off the runtime (#1364).
1320            log_and_map_removal(&req.path, removed)
1321        } else {
1322            // "Close Window" with no owning window is a no-op success; a
1323            // self-close replies and the extension closes its own window.
1324            log_window_closed(&req.path);
1325            Ok(json!({ "closed": true }))
1326        }
1327    }
1328
1329    /// Signals each of `others` to close itself and waits for every window other
1330    /// than `requester` that has `path` open to unregister.
1331    ///
1332    /// When the wait fails the directives are withdrawn before the error is
1333    /// propagated (#2139). The caller is told "window(s) did not close in time"
1334    /// and the worktree is left intact, so a window that was merely slow must not
1335    /// then close itself on a later heartbeat, after the user was told the close
1336    /// failed. Every key is withdrawn, including one for a window that has since
1337    /// been reaped: it too would otherwise close itself if it came back.
1338    ///
1339    /// On success the directives are left alone. A window that unregistered
1340    /// cleared its own, and one counted closed because it was *reaped* keeps its
1341    /// key on purpose: the op is about to remove the worktree it had open (or was
1342    /// asked to close that window), so a hung window that later returns should be
1343    /// handed `close: true` on its `known: false` heartbeat rather than stay open
1344    /// on a deleted folder.
1345    ///
1346    /// A directive the window already took cannot be recalled, so a window that
1347    /// received `close: true` and is closing slowly still closes — that is the
1348    /// action the user asked for. There is deliberately no drop guard: a
1349    /// connection handler runs a one-reply op to completion (a client that
1350    /// disconnects does not cancel it), so the only cancellation is a daemon
1351    /// shutdown, which takes the in-memory set with it. `timeout`/`poll` are
1352    /// parameters (as in [`await_windows_closed`]) so a test can drive the
1353    /// timeout without a 20 s wait.
1354    async fn signal_and_await_close(
1355        &self,
1356        path: &Path,
1357        requester: Option<&str>,
1358        others: &[String],
1359        timeout: Duration,
1360        poll: Duration,
1361    ) -> Result<()> {
1362        for key in others {
1363            self.registry.mark_close_pending(key);
1364        }
1365        let waited = await_windows_closed(&self.registry, path, requester, timeout, poll).await;
1366        if waited.is_err() {
1367            for key in others {
1368                self.registry.cancel_close_pending(key);
1369            }
1370        }
1371        waited
1372    }
1373
1374    /// Handles the `reload` op (#1417): signal each target window to reload
1375    /// itself, returning `{ requested, signalled, unknown }`.
1376    ///
1377    /// Synchronous, and deliberately so — the whole op is a set insert per key.
1378    /// It marks a directive on each *currently registered* target and returns;
1379    /// the window acts on it on its next `heartbeat`, up to the ~10s cadence
1380    /// later. Unlike [`close`](Self::close) it never waits, because a reload has
1381    /// no completion the daemon can observe (the window re-registers under the
1382    /// same key), which is why the reply says `signalled`, never `reloaded`.
1383    ///
1384    /// A key with no live window is reported in `unknown` rather than erroring:
1385    /// the batch is a sweep, and a window closing between the client rendering
1386    /// its list and sending the op is routine, not a failure. `list()` reaps
1387    /// stale entries on read, so a window that died without unregistering is
1388    /// correctly unknown here.
1389    fn reload(&self, req: ReloadRequest) -> Value {
1390        let live: HashSet<String> = self
1391            .registry
1392            .list()
1393            .into_iter()
1394            .map(|entry| entry.key)
1395            .collect();
1396
1397        let mut seen = HashSet::new();
1398        let mut signalled = 0usize;
1399        let mut unknown = Vec::new();
1400        for key in &req.target_keys {
1401            // A client repeating a key asks for one reload, not two.
1402            if !seen.insert(key.as_str()) {
1403                continue;
1404            }
1405            if live.contains(key) {
1406                self.registry.mark_reload_pending(key);
1407                signalled += 1;
1408            } else {
1409                unknown.push(key.clone());
1410            }
1411        }
1412
1413        log_reload(seen.len(), signalled, &unknown);
1414        json!({
1415            "requested": seen.len(),
1416            "signalled": signalled,
1417            "unknown": unknown,
1418        })
1419    }
1420
1421    /// Handles the `merge-queue` op (#1401): batch-enqueue the eligible worktrees'
1422    /// PRs into the GitHub merge queue. Two-phase, keyed off `confirmed`:
1423    ///
1424    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run the
1425    ///   side-effect-free eligibility evaluation ([`evaluate_batch`]) and return an
1426    ///   [`EligibilityReport`]: the enqueue-eligible worktrees and the skipped ones
1427    ///   (each with a machine `kind` + human `detail`).
1428    /// - **Phase 2** (`confirmed:true`) — **re-run** the same evaluation (never
1429    ///   trust a phase-1 result the client sent, exactly as `close` re-validates on
1430    ///   execute), then enqueue each still-eligible PR. A per-PR rejection lands in
1431    ///   `failed[]`; the batch never fails as a whole.
1432    ///
1433    /// All git and `gh` I/O runs on a blocking thread — never the async worker and
1434    /// never under the registry lock. No lock is taken: each enqueue mutates a
1435    /// distinct remote PR, not a shared local resource.
1436    async fn merge_queue(&self, req: MergeQueueRequest) -> Result<Value> {
1437        // Resolved once here (the env read is process-stable), then handed to the
1438        // seam below — the `open_prs`/`open_prs_with` "bin as a param" pattern, so a
1439        // test drives the eligibility + enqueue paths against a fake `gh` without
1440        // touching the environment (#1030).
1441        self.merge_queue_with(req, crate::pr_status::resolve_gh_binary())
1442            .await
1443    }
1444
1445    /// [`merge_queue`](Self::merge_queue) with an explicit `gh` binary, so a test
1446    /// exercises the phase-1 network resolve and the phase-2 enqueue against a stub.
1447    async fn merge_queue_with(&self, req: MergeQueueRequest, bin: PathBuf) -> Result<Value> {
1448        // Report-only unless explicitly confirmed; an explicit `check` request
1449        // always reports and never enqueues.
1450        let report_only = req.check || !req.confirmed;
1451
1452        let eval_bin = bin.clone();
1453        let eval_paths = req.paths.clone();
1454        let (eligible, skipped) =
1455            tokio::task::spawn_blocking(move || evaluate_batch(&eval_bin, &eval_paths))
1456                .await
1457                .map_err(|e| anyhow!("merge-queue eligibility task panicked: {e}"))
1458                .and_then(|inner| inner)?;
1459
1460        if report_only {
1461            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1462            log_merge_check(&req, eligible.len(), skipped.len());
1463            let eligible: Vec<PrRef> = eligible.iter().map(PrRef::from).collect();
1464            return Ok(
1465                serde_json::to_value(EligibilityReport { eligible, skipped })
1466                    .unwrap_or_else(|_| json!({})),
1467            );
1468        }
1469
1470        // Phase 2: enqueue the freshly re-validated eligible set, sequentially.
1471        let enqueue_bin = bin.clone();
1472        let (queued, failed) =
1473            tokio::task::spawn_blocking(move || enqueue_eligible(&enqueue_bin, eligible))
1474                .await
1475                .map_err(|e| anyhow!("merge-queue enqueue task panicked: {e}"))?;
1476        log_merge_enqueue(&req, queued.len(), failed.len(), skipped.len());
1477        Ok(serde_json::to_value(EnqueueResult {
1478            queued,
1479            skipped,
1480            failed,
1481        })
1482        .unwrap_or_else(|_| json!({})))
1483    }
1484
1485    /// Handles the `rebase` op (#1415): batch-rebase the selected worktrees onto
1486    /// their repository's remote default branch, fetching it **once per
1487    /// repository**. Two-phase, keyed off `confirmed`, exactly like `merge-queue`:
1488    ///
1489    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1490    ///   [`worktree_rebase::plan`], which fetches once per repo and classifies
1491    ///   every selected worktree. This *is* the "only rebase if it makes sense
1492    ///   from the current git state" gate: the classifier skips a detached HEAD, a
1493    ///   dirty tree, an operation already in progress, a non-worktree path, an
1494    ///   unresolvable onto ref, and anything already up to date — the main working
1495    ///   tree is a valid target like any other (ADR-0060). Side-effect-free apart
1496    ///   from the fetch, which only advances a remote-tracking ref.
1497    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch** (never trust a
1498    ///   phase-1 result the client sent back, as `close` and `merge-queue` do),
1499    ///   then execute. A worktree that went dirty between the phases is skipped
1500    ///   rather than rebased.
1501    ///
1502    /// **Why the daemon may do this at all** (ADR-0059): ADR-0055 confined the
1503    /// rebase to the CLI on the premise that the daemon could not authenticate a
1504    /// fetch. It can — launchd exports `SSH_AUTH_SOCK` into the per-user session,
1505    /// so the daemon inherits the user's `ssh-agent`. The real gap was the minimal
1506    /// `PATH`, closed by [`crate::git::resolve_git_binary`].
1507    ///
1508    /// All git I/O runs on a blocking thread, never the async worker and never
1509    /// under the registry lock.
1510    async fn rebase(&self, req: RebaseRequest) -> Result<Value> {
1511        // Resolved once here (the probe is process-stable), then handed to the
1512        // seam below — the `merge_queue_with` "bin as a param" pattern, so a test
1513        // drives both phases against a stub without touching the environment.
1514        self.rebase_with(req, crate::git::resolve_git_binary())
1515            .await
1516    }
1517
1518    /// [`rebase`](Self::rebase) with an explicit `git` binary, so a test exercises
1519    /// the plan and execute paths against a stub.
1520    async fn rebase_with(&self, req: RebaseRequest, git_bin: PathBuf) -> Result<Value> {
1521        if req.paths.is_empty() {
1522            bail!("`rebase` requires at least one path");
1523        }
1524        // Report-only unless explicitly confirmed; an explicit `check` request
1525        // always reports and never rebases.
1526        let report_only = req.check || !req.confirmed;
1527        let opts = req.options(git_bin);
1528        let selection = Selection::Paths(req.paths.clone());
1529
1530        if report_only {
1531            // Phase 1: fetch once per repo and classify, rebase nothing. No lock —
1532            // it mutates no worktree, and a plan is allowed to race an execute.
1533            let plan = plan_rebase(&selection, &opts).await?;
1534            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1535            log_rebase_check(&req, &plan);
1536            return Ok(rebase_reply(&plan.fetches, &plan.worktrees));
1537        }
1538
1539        // Phase 2: re-plan and execute, serialized against other executes —
1540        // linked worktrees share one object database (see `rebase_lock`).
1541        //
1542        // The lock is taken **before** the re-plan, not merely around the execute,
1543        // and that ordering is load-bearing. A plan taken outside it can be
1544        // invalidated by a concurrent execute before this one gets its turn — and
1545        // acting on a stale plan is not benign: if the other run left a worktree
1546        // mid-rebase, a stale `WouldRebase` here would run `git rebase` against a
1547        // repository that is already mid-rebase and (without `keep_conflicts`)
1548        // `--abort` it, destroying exactly the conflict resolution the other run
1549        // was preserving. Planning under the lock means the classifier sees that
1550        // worktree's real state and skips it as `operation-in-progress`.
1551        let _guard = self.rebase_lock.lock().await;
1552        let plan = plan_rebase(&selection, &opts).await?;
1553        // The worktrees actually about to be rewritten, canonicalized here (disk
1554        // I/O belongs in the adapter, not the registry) so they match the tree
1555        // snapshot's own keys.
1556        let pending: Vec<PathBuf> = plan
1557            .worktrees
1558            .iter()
1559            .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
1560            .map(|w| canonical(&w.path))
1561            .collect();
1562        self.registry.mark_rebasing(&pending);
1563        let fetches = plan.fetches.clone();
1564        let exec_opts = opts.clone();
1565        let outcomes =
1566            tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &exec_opts)).await;
1567        // Cleared on **every** exit, including a panicked task, so a failed rebase
1568        // can never leave a permanent spinner on a tree row.
1569        self.registry.clear_rebasing(&pending);
1570        let outcomes = outcomes.map_err(|e| anyhow!("rebase task panicked: {e}"))?;
1571
1572        log_rebase_execute(&req, &outcomes);
1573        Ok(rebase_reply(&fetches, &outcomes))
1574    }
1575
1576    /// Handles the `push` op (#1443): publish the selected worktrees' branches to
1577    /// their upstreams, force-pushing **with a lease** where a rebase rewrote
1578    /// history. The complement of [`rebase`](Self::rebase), and two-phase in
1579    /// exactly the same way:
1580    ///
1581    /// - **Phase 1** (`check:true`, or any un-`confirmed` request) — run
1582    ///   [`worktree_push::plan`], which classifies every selected worktree against
1583    ///   its upstream. This *is* the "does this make sense?" gate: it skips a
1584    ///   detached HEAD (which also covers a worktree mid-rebase), a non-worktree
1585    ///   path, a branch with nowhere to publish, and a force-push of the
1586    ///   repository's remote default branch — while a dirty tree is deliberately
1587    ///   **not** a skip, since a push publishes commits rather than the working
1588    ///   tree. Unlike `rebase`'s phase 1 this is not merely side-effect-*light*: it
1589    ///   contacts no remote at all (ADR-0061).
1590    /// - **Phase 2** (`confirmed:true`) — **re-plan from scratch**, then execute. A
1591    ///   phase-1 result the client sends back is never trusted, as `close`,
1592    ///   `merge-queue` and `rebase` all re-validate. A branch that moved between
1593    ///   the phases is re-classified, not pushed on stale information.
1594    ///
1595    /// **Why the daemon may do this at all** (ADR-0061): this is the first op to
1596    /// write to a remote using the user's ambient *git* credentials — `merge-queue`
1597    /// mutates the remote through `gh` (ADR-0056) and `rebase` only fetches
1598    /// (ADR-0059). It stays same-user-bounded behind the `0600` socket, publishes
1599    /// only branches the client names, and can never overwrite work it has not
1600    /// seen, because the lease is enforced by `git` itself.
1601    ///
1602    /// All git I/O runs on a blocking thread, never the async worker and never
1603    /// under the registry lock.
1604    async fn push(&self, req: PushRequest) -> Result<Value> {
1605        // Resolved once here (the probe is process-stable), then handed to the
1606        // seam below — the `rebase_with` "bin as a param" pattern.
1607        self.push_with(req, crate::git::resolve_git_binary()).await
1608    }
1609
1610    /// [`push`](Self::push) with an explicit `git` binary, so a test exercises the
1611    /// plan and execute paths against a stub.
1612    async fn push_with(&self, req: PushRequest, git_bin: PathBuf) -> Result<Value> {
1613        if req.paths.is_empty() {
1614            bail!("`push` requires at least one path");
1615        }
1616        // Report-only unless explicitly confirmed; an explicit `check` request
1617        // always reports and never pushes.
1618        let report_only = req.check || !req.confirmed;
1619        let selection = Selection::Paths(req.paths.clone());
1620
1621        if report_only {
1622            // Phase 1: classify only. No lock and no network — planning reads the
1623            // local remote-tracking refs, which is exactly what the lease is
1624            // checked against.
1625            let plan = plan_push(&selection).await?;
1626            // Auditable in `omni-dev daemon logs` (ADR-0049 §6 precedent).
1627            log_push_check(&req, &plan);
1628            return Ok(push_reply(&plan.worktrees));
1629        }
1630
1631        // Phase 2: re-plan and execute, serialized against other executes — a push
1632        // writes into the ref store linked worktrees share (see `push_lock`). The
1633        // lock is taken **before** the re-plan for the same reason `rebase` takes
1634        // its own that way: a plan plus a lease are only meaningful together, and a
1635        // plan taken outside the lock can be invalidated before its turn comes.
1636        let _guard = self.push_lock.lock().await;
1637        let plan = plan_push(&selection).await?;
1638        // The worktrees actually about to be published, canonicalized here (disk
1639        // I/O belongs in the adapter, not the registry) so they match the tree
1640        // snapshot's own keys.
1641        let pending: Vec<PathBuf> = plan
1642            .worktrees
1643            .iter()
1644            .filter(|w| w.result.is_pending())
1645            .map(|w| canonical(&w.path))
1646            .collect();
1647        self.registry.mark_pushing(&pending);
1648        let opts = worktree_push::PushOptions {
1649            git_bin: Some(git_bin),
1650        };
1651        let outcomes =
1652            tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts)).await;
1653        // Cleared on **every** exit, including a panicked task. A push writes no
1654        // on-disk state, so this set is the *whole* cue — a mark left behind would
1655        // be a permanent spinner nothing else could correct.
1656        self.registry.clear_pushing(&pending);
1657        let outcomes = outcomes.map_err(|e| anyhow!("push task panicked: {e}"))?;
1658
1659        log_push_execute(&req, &outcomes);
1660        Ok(push_reply(&outcomes))
1661    }
1662
1663    /// Handles the `reposition` op (#1407): move and resize each target worktree's
1664    /// **already-open** VS Code window to match the invoking window's geometry.
1665    ///
1666    /// The invoking window is the reference: it supplies the frame and is never
1667    /// itself moved. A target with no open window, no resolvable OS window, or an
1668    /// ambiguous name is reported rather than guessed at. Z-order is untouched —
1669    /// see [`geometry::ax`] for why the Accessibility API gives that for free, and
1670    /// why this must **not** reuse [`focus_window`], which deliberately raises.
1671    ///
1672    /// `check: true` is a dry run: everything resolves exactly as it would for a
1673    /// real run, but nothing is written. That is the whole diagnostic surface for
1674    /// title matching (`worktrees reposition --dry-run`).
1675    ///
1676    /// Not two-phase like `close`/`merge-queue`: nothing durable is created,
1677    /// modified, or destroyed, so a confirmation on a routine layout command would
1678    /// cost more than it protects. Reversibility is provided instead, by
1679    /// [`reposition_undo`](Self::reposition_undo).
1680    async fn reposition(&self, req: RepositionRequest) -> Result<Value> {
1681        self.reposition_with(req, geometry::ax::AxBackend::new)
1682            .await
1683    }
1684
1685    /// [`reposition`](Self::reposition) with the platform backend injected as a
1686    /// **factory**, so a test drives the whole op — key resolution, the undo
1687    /// store, the reply shape — against a fake with no `unsafe` and no windows.
1688    ///
1689    /// A factory rather than the backend itself because the real backend holds
1690    /// CoreFoundation references and so is neither `Send` nor `Sync`: it has to be
1691    /// built *inside* the blocking closure. The `merge_queue_with` "seam as a
1692    /// parameter" pattern, one level of indirection over.
1693    async fn reposition_with<B, F>(&self, req: RepositionRequest, make_backend: F) -> Result<Value>
1694    where
1695        B: geometry::WindowBackend,
1696        F: FnOnce() -> B + Send + 'static,
1697    {
1698        if req.reference_key.trim().is_empty() {
1699            bail!("`reposition` requires a non-empty `reference_key`");
1700        }
1701        // Resolve keys against the registry *here*, so all AX work below deals in
1702        // plain data and the registry lock is never held across the blocking join.
1703        let entries = self.registry.list();
1704        let reference = registered_window(&entries, &req.reference_key);
1705        if !reference.live {
1706            // Unlike a target, an unresolvable *reference* is a hard error: there
1707            // is no geometry to copy, so the request cannot mean anything.
1708            bail!(
1709                "no open window with key {} (it may have closed)",
1710                req.reference_key
1711            );
1712        }
1713        // A target key with no live window is a reportable per-target outcome, not
1714        // a failure of the batch — the tree row may simply be a tick stale.
1715        let targets: Vec<geometry::RegisteredWindow> = req
1716            .target_keys
1717            .iter()
1718            .map(|key| registered_window(&entries, key))
1719            .collect();
1720
1721        let check = req.check;
1722        let mut report = tokio::task::spawn_blocking(move || {
1723            // The backend lives for exactly one op, so its enumeration cache can
1724            // never serve a window that has since moved or closed.
1725            let backend = make_backend();
1726            geometry::reposition(&backend, &reference, &targets, check)
1727        })
1728        .await
1729        .map_err(|e| anyhow!("reposition task panicked: {e}"))?;
1730
1731        // Taken out of the report rather than cloned: nothing downstream reads it,
1732        // and the store is its only owner. A dry run leaves the previous batch's
1733        // record intact — it changed nothing, so there is neither something new to
1734        // undo nor something stale to discard.
1735        let undo = std::mem::take(&mut report.undo);
1736        let undoable = !check && !undo.is_empty();
1737        if undoable {
1738            *self
1739                .reposition_undo
1740                .lock()
1741                .unwrap_or_else(PoisonError::into_inner) = undo;
1742        }
1743        log_reposition(&req, &report);
1744        Ok(reposition_reply(&report, undoable))
1745    }
1746
1747    /// Handles the `reposition-undo` op (#1407): put the windows the last
1748    /// `reposition` moved back where they were.
1749    ///
1750    /// Consumes the stored batch, so an undo cannot be replayed onto windows the
1751    /// user has since arranged by hand. Every window is re-resolved from scratch —
1752    /// one that has closed, been renamed, or gone fullscreen in the meantime is
1753    /// reported, not forced.
1754    async fn reposition_undo(&self) -> Result<Value> {
1755        self.reposition_undo_with(geometry::ax::AxBackend::new)
1756            .await
1757    }
1758
1759    /// [`reposition_undo`](Self::reposition_undo) with the backend factory
1760    /// injected, for the same reasons as
1761    /// [`reposition_with`](Self::reposition_with).
1762    async fn reposition_undo_with<B, F>(&self, make_backend: F) -> Result<Value>
1763    where
1764        B: geometry::WindowBackend,
1765        F: FnOnce() -> B + Send + 'static,
1766    {
1767        let stored = std::mem::take(
1768            &mut *self
1769                .reposition_undo
1770                .lock()
1771                .unwrap_or_else(PoisonError::into_inner),
1772        );
1773        if stored.is_empty() {
1774            return Ok(json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 }));
1775        }
1776        let entries = self.registry.list();
1777        let restore: Vec<(geometry::RegisteredWindow, geometry::Frame)> = stored
1778            .into_iter()
1779            .map(|(key, frame)| (registered_window(&entries, &key), frame))
1780            .collect();
1781
1782        let report = tokio::task::spawn_blocking(move || {
1783            let backend = make_backend();
1784            geometry::restore(&backend, &restore)
1785        })
1786        .await
1787        .map_err(|e| anyhow!("reposition-undo task panicked: {e}"))?;
1788        log_reposition_undo(&report);
1789        Ok(reposition_reply(&report, false))
1790    }
1791}
1792
1793impl Default for WorktreesService {
1794    fn default() -> Self {
1795        Self::new()
1796    }
1797}
1798
1799#[async_trait]
1800impl DaemonService for WorktreesService {
1801    fn name(&self) -> &'static str {
1802        SERVICE_NAME
1803    }
1804
1805    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1806        match op {
1807            "register" => {
1808                let req: RegisterRequest =
1809                    serde_json::from_value(payload).context("invalid `register` payload")?;
1810                if req.key.trim().is_empty() {
1811                    bail!("`register` requires a non-empty `key`");
1812                }
1813                self.registry.register(req);
1814                Ok(json!({ "ok": true }))
1815            }
1816            "heartbeat" => {
1817                let key = require_str(&payload, "key", "heartbeat")?;
1818                let known = self.registry.heartbeat(key);
1819                // A pending close directive (#1277) rides the reply as an
1820                // additive `close` field, taken-and-cleared here so it fires
1821                // exactly once. Omitted when false to keep older windows — which
1822                // read only `known` — byte-identical on the wire.
1823                let mut reply = json!({ "known": known });
1824                if self.registry.take_close_pending(key) {
1825                    reply["close"] = Value::Bool(true);
1826                }
1827                // A pending reload directive (#1417) rides the same reply, on
1828                // the same terms. Deliberately an independent `if`, not an
1829                // `else`: each field then means exactly "this directive was
1830                // pending", and each is taken exactly once regardless of the
1831                // other. The companion resolves a both-set collision by
1832                // checking `close` first, since closing subsumes reloading.
1833                if self.registry.take_reload_pending(key) {
1834                    reply["reload"] = Value::Bool(true);
1835                }
1836                Ok(reply)
1837            }
1838            "unregister" => {
1839                let key = require_str(&payload, "key", "unregister")?;
1840                Ok(json!({ "removed": self.registry.unregister(key) }))
1841            }
1842            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1843            "tree" => {
1844                // The same `{ repos, show_closed }` snapshot the `subscribe`
1845                // stream pushes, so a one-shot `tree` fetch and the live stream
1846                // agree byte-for-byte (the git enumeration runs off-lock on a
1847                // blocking thread inside the helper). Computed fresh here — the
1848                // `tree` op is a rare manual refresh, deliberately bypassing the
1849                // stream's coalescing cache so it never returns a stale view
1850                // (#1303).
1851                Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1852            }
1853            "ahead-behind" => {
1854                // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1855                // snapshot no longer carries ahead/behind — the dominant
1856                // per-worktree cost when computed eagerly every tick — so a client
1857                // (the extension on expand, `worktrees tree`) asks for it here only
1858                // for the worktrees it is about to show. Batched by path, one op per
1859                // repo expand; the git walks run on a blocking thread.
1860                let paths = payload
1861                    .get("paths")
1862                    .and_then(Value::as_array)
1863                    .map(|arr| {
1864                        arr.iter()
1865                            .filter_map(Value::as_str)
1866                            .map(PathBuf::from)
1867                            .collect::<Vec<_>>()
1868                    })
1869                    .unwrap_or_default();
1870                Ok(json!({ "results": ahead_behind_results(&self.ahead_behind, paths).await }))
1871            }
1872            "set-show-closed" => {
1873                // The daemon-backed show/hide-closed toggle (#1301). Setting it
1874                // bumps the change-notify, so every subscribed window re-pushes a
1875                // snapshot carrying the new `show_closed` — reliable cross-window
1876                // sync `context.globalState` could not do.
1877                let show_closed = payload
1878                    .get("show_closed")
1879                    .and_then(Value::as_bool)
1880                    .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1881                self.registry.set_show_closed(show_closed);
1882                Ok(json!({ "ok": true }))
1883            }
1884            "set-polling" => {
1885                // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1886                // poller resolving its badges (default is off — zero `gh`);
1887                // disabling stops it and drops its badges. `set_polling` bumps the
1888                // change-notify on a real change, so every subscribed window
1889                // re-pushes a `tree` snapshot carrying the new per-repo
1890                // `polling_enabled` — the reliable cross-window sync the
1891                // `set-show-closed` precedent relies on. A changed value is
1892                // persisted so it survives a daemon restart.
1893                let owner = require_str(&payload, "owner", "set-polling")?;
1894                let name = require_str(&payload, "name", "set-polling")?;
1895                let enabled = payload
1896                    .get("enabled")
1897                    .and_then(Value::as_bool)
1898                    .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1899                if owner.trim().is_empty() || name.trim().is_empty() {
1900                    bail!("`set-polling` requires a non-empty `owner` and `name`");
1901                }
1902                if self.registry.set_polling(owner, name, enabled) {
1903                    self.persist_polling_prefs();
1904                }
1905                Ok(json!({ "ok": true }))
1906            }
1907            "open-prs" => {
1908                // Serve "Open Pull Request…" (and the extension's transient badge
1909                // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1910                // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1911                // call instead of each shelling its own (the per-window burn
1912                // #1370/#1389 target). Repo-wide; the client filters by branch for a
1913                // worktree-scoped lookup, and answers a badged branch straight from
1914                // the snapshot (zero `gh`) without ever reaching here.
1915                let owner = require_str(&payload, "owner", "open-prs")?;
1916                let name = require_str(&payload, "name", "open-prs")?;
1917                if owner.trim().is_empty() || name.trim().is_empty() {
1918                    bail!("`open-prs` requires a non-empty `owner` and `name`");
1919                }
1920                Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1921            }
1922            "open" => {
1923                // Focus (or open — VS Code reuses an already-open window) an
1924                // arbitrary worktree folder supplied by a socket client, reusing
1925                // the tray's launcher path: `focus_window` resolves the launcher
1926                // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1927                // the absolute-existing-directory guard (which also blocks a
1928                // `-`-leading path being parsed by `code` as a flag). This is the
1929                // one op a socket *writer* can use to spawn `code`; see the
1930                // ADR-0040 threat model (#1266).
1931                let path = require_str(&payload, "path", "open")?;
1932                focus_window(Path::new(path))?;
1933                Ok(json!({ "ok": true }))
1934            }
1935            "close" => {
1936                // Close a worktree's window and (for a linked worktree)
1937                // **delete** it. Destructive, so all git logic stays in the
1938                // daemon (git2, never a shell) and the main working tree is
1939                // refused defensively — the UI gating is not the only guard.
1940                // See ADR-0049 and docs/worktrees-service.md.
1941                let req: CloseRequest =
1942                    serde_json::from_value(payload).context("invalid `close` payload")?;
1943                self.close(req).await
1944            }
1945            "reload" => {
1946                // Signal each target window to reload itself (#1417). Addressed
1947                // by window key like `reposition`, not by path like `close`: a
1948                // reload acts on a *window*, and one tree row is one window,
1949                // whereas a path can be open in several. Nothing here is
1950                // destructive and nothing waits — the directive is marked and
1951                // the reply says only what was *signalled*. See
1952                // docs/worktrees-service.md.
1953                let req: ReloadRequest =
1954                    serde_json::from_value(payload).context("invalid `reload` payload")?;
1955                Ok(self.reload(req))
1956            }
1957            "merge-queue" => {
1958                // Batch-enqueue eligible worktrees' PRs into the GitHub merge
1959                // queue (#1401). Two-phase like `close` (side-effect-free
1960                // eligibility check → confirmed enqueue) and daemon-re-validated,
1961                // but a single batched op over `paths`. All git/`gh` work runs on
1962                // a blocking thread; enqueue authenticates through the user's own
1963                // `gh`. See ADR-0056 and docs/worktrees-service.md.
1964                let req: MergeQueueRequest =
1965                    serde_json::from_value(payload).context("invalid `merge-queue` payload")?;
1966                self.merge_queue(req).await
1967            }
1968            "rebase" => {
1969                // Batch-rebase the selected worktrees onto their repo's remote
1970                // default branch, fetching once per repo (#1415). Two-phase like
1971                // `close`/`merge-queue` (side-effect-free plan → confirmed
1972                // execute) and daemon-re-validated. The fetch authenticates
1973                // through the user's own `ssh-agent`, which launchd exports into
1974                // the daemon's environment — the premise ADR-0055 got wrong. All
1975                // git work runs on a blocking thread. See ADR-0059, ADR-0055 and
1976                // docs/worktrees-service.md.
1977                let req: RebaseRequest =
1978                    serde_json::from_value(payload).context("invalid `rebase` payload")?;
1979                self.rebase(req).await
1980            }
1981            "push" => {
1982                // Publish the selected worktrees' branches to their upstreams,
1983                // force-pushing **with a lease** where a rebase rewrote history
1984                // (#1443). Two-phase like `rebase` (side-effect-free plan →
1985                // confirmed execute) and daemon-re-validated, but its plan phase
1986                // contacts no remote at all. The push authenticates through the
1987                // user's own ambient git credentials — the first op to *write* to
1988                // a remote that way. There is no force escape hatch and no remote
1989                // override: a refused lease is the feature working. See ADR-0061
1990                // and docs/worktrees-service.md.
1991                let req: PushRequest =
1992                    serde_json::from_value(payload).context("invalid `push` payload")?;
1993                self.push(req).await
1994            }
1995            "reposition" => {
1996                // Move each target's already-open VS Code window onto the invoking
1997                // window's geometry (#1407). The one op that reaches outside the
1998                // process to control another application's windows, so all of its
1999                // OS interaction is confined to the `geometry::ax` module behind
2000                // the macOS Accessibility permission — geometry only, never a
2001                // raise, so Z-order is untouched. All AX I/O runs on a blocking
2002                // thread. See ADR-0058 and docs/worktrees-service.md.
2003                let req: RepositionRequest =
2004                    serde_json::from_value(payload).context("invalid `reposition` payload")?;
2005                self.reposition(req).await
2006            }
2007            "reposition-undo" => {
2008                // Put the windows the last `reposition` moved back where they were
2009                // (#1407). Payload-free: the daemon holds the one-level undo
2010                // record, so the client cannot ask to restore arbitrary geometry.
2011                self.reposition_undo().await
2012            }
2013            other => bail!("unknown worktrees op: {other}"),
2014        }
2015    }
2016
2017    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
2018        // The single streaming op: a live push of the repo/worktree `tree`
2019        // snapshot. Every other op falls through to the request→reply `handle`.
2020        if op != "subscribe" {
2021            return None;
2022        }
2023        Some(Box::new(WorktreesStream {
2024            // Every stream reads through the one shared cache, so N windows
2025            // sampling the same tick build the tree once, not N times (#1303).
2026            cache: self.tree_cache.clone(),
2027            // Capture the change source *now* — before the server takes its
2028            // initial snapshot — so a change racing that snapshot still wakes us.
2029            changes: self.registry.subscribe_changes(),
2030        }))
2031    }
2032
2033    fn menu(&self) -> MenuSnapshot {
2034        // Serve the snapshot the background task maintains off the main thread;
2035        // fall back to a one-off inline compute only before the first refresh
2036        // lands (or with no runtime — the unit tests). Never blocks on git here
2037        // in the daemon, honouring the trait's "cheap, must not block" contract.
2038        let cached = self
2039            .menu_cache
2040            .lock()
2041            .unwrap_or_else(PoisonError::into_inner)
2042            .clone();
2043        let items = cached.unwrap_or_else(|| {
2044            menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
2045        });
2046        MenuSnapshot {
2047            title: SUBMENU_TITLE.to_string(),
2048            items,
2049        }
2050    }
2051
2052    async fn menu_action(&self, action_id: &str) -> Result<()> {
2053        if let Some(key) = action_id.strip_prefix("focus:") {
2054            // The registry resolves the folder under its own lock and clones it
2055            // out, so the mutex is never held across the process launch.
2056            let folder = self
2057                .registry
2058                .first_folder(key)
2059                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
2060            focus_window(&folder)?;
2061            return Ok(());
2062        }
2063        bail!("unknown worktrees menu action: {action_id}")
2064    }
2065
2066    async fn status(&self) -> ServiceStatus {
2067        let entries = self.registry.list();
2068        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
2069        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
2070        let windows = enriched_windows(entries).await;
2071        ServiceStatus {
2072            name: SERVICE_NAME.to_string(),
2073            healthy: true,
2074            summary,
2075            detail: json!({ "windows": windows }),
2076        }
2077    }
2078
2079    fn credit_accept_outage(&self, outage: Duration) {
2080        self.registry.credit_outage(outage);
2081    }
2082
2083    async fn shutdown(&self) {
2084        // Stop the background menu-refresh task; the registry itself is in-memory
2085        // with nothing to drain or persist. Take the task out from under the lock
2086        // first so the `std::Mutex` is never held across the `.await`.
2087        let task = self
2088            .refresh
2089            .lock()
2090            .unwrap_or_else(PoisonError::into_inner)
2091            .take();
2092        if let Some(task) = task {
2093            task.token.cancel();
2094            let _ = task.handle.await;
2095        }
2096        // Same discipline for the PR badge poller (#1337): take it out from under
2097        // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
2098        let poller = self
2099            .poller
2100            .lock()
2101            .unwrap_or_else(PoisonError::into_inner)
2102            .take();
2103        if let Some(poller) = poller {
2104            poller.token.cancel();
2105            let _ = poller.handle.await;
2106        }
2107        // And the GitHub rate-limit poller (#1375), same discipline.
2108        let rate_limit_poller = self
2109            .rate_limit_poller
2110            .lock()
2111            .unwrap_or_else(PoisonError::into_inner)
2112            .take();
2113        if let Some(poller) = rate_limit_poller {
2114            poller.token.cancel();
2115            let _ = poller.handle.await;
2116        }
2117    }
2118}
2119
2120/// Extracts a required string `field` from an op payload, erroring with the op
2121/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
2122/// (`key`) and `open` (`path`).
2123fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
2124    payload
2125        .get(field)
2126        .and_then(Value::as_str)
2127        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
2128}
2129
2130/// The live git state of a worktree folder: the checked-out branch and how far
2131/// it has diverged from its upstream. Computed on read from the on-disk repo
2132/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
2133/// snapshot taken at registration.
2134///
2135/// Every field is optional and degrades independently: a folder that is not a
2136/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
2137/// listed — just without the fields it cannot supply. The `skip_serializing_if`
2138/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
2139/// omitting each absent field on the wire.
2140#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
2141struct GitStatus {
2142    /// The checked-out branch, or `None` when detached or not in a repo.
2143    #[serde(skip_serializing_if = "Option::is_none")]
2144    branch: Option<String>,
2145    /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
2146    /// even on a detached HEAD, which has a commit but no branch. Rides the
2147    /// streamed snapshot so a new commit is a real delta the server's diff cannot
2148    /// drop — without it, a push serialises byte-identically and no client
2149    /// re-renders (#1337).
2150    #[serde(skip_serializing_if = "Option::is_none")]
2151    head_sha: Option<String>,
2152    /// The commit the branch's configured upstream ref points at, or `None`
2153    /// without an upstream (or when detached, unborn, or not in a repo). Rides
2154    /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
2155    /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
2156    /// field — `head_sha` included — byte-identical, so without this the frame
2157    /// serialised the same, the server's diff dropped it, and the lazily-fetched
2158    /// ahead/behind was never re-asked (#1344).
2159    #[serde(skip_serializing_if = "Option::is_none")]
2160    upstream_sha: Option<String>,
2161    /// Commits the branch is ahead of its upstream (`None` without an upstream).
2162    #[serde(skip_serializing_if = "Option::is_none")]
2163    ahead: Option<usize>,
2164    /// Commits the branch is behind its upstream (`None` without an upstream).
2165    #[serde(skip_serializing_if = "Option::is_none")]
2166    behind: Option<usize>,
2167    /// The main repository's directory name — the parent repo for a linked
2168    /// worktree, the checkout's own directory otherwise. Derived from git's
2169    /// common dir so a worktree names the repo it belongs to rather than its
2170    /// worktree-folder basename. `None` when not in a repo.
2171    #[serde(skip_serializing_if = "Option::is_none")]
2172    main_repo: Option<String>,
2173    /// Whether the enriched folder is a **linked** git worktree rather than the
2174    /// repository's main working tree. Omitted (false) for a normal checkout.
2175    #[serde(skip_serializing_if = "is_false")]
2176    is_worktree: bool,
2177    /// The multi-step git operation the worktree is in the middle of (#1415):
2178    /// `rebase`, `rebase-interactive`, `merge`, `cherry-pick`, `revert`, `bisect`
2179    /// or `apply-mailbox`. `None` for a clean worktree (the overwhelming case), so
2180    /// the field is omitted on the wire and an older client is byte-identical.
2181    ///
2182    /// This is the **durable** half of the tree's rebase cue: read fresh off disk
2183    /// on every snapshot, it survives a daemon restart and keeps showing a
2184    /// conflict the `rebase` op left in place until the user resolves it. The
2185    /// transient half — "the daemon is rebasing this right now" — comes from the
2186    /// registry's in-memory set instead (see [`TreeWorktree::rebasing`]).
2187    ///
2188    /// Cheap enough for the every-worktree-every-tick snapshot (#1306's bar):
2189    /// `Repository::state()` stats a handful of paths under `.git`, which is
2190    /// nothing beside the `Repository::discover` this function already does — and
2191    /// unlike `graph_ahead_behind` it is neither a revwalk nor an object lookup.
2192    #[serde(skip_serializing_if = "Option::is_none")]
2193    operation: Option<String>,
2194}
2195
2196/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
2197/// field is dropped on the wire unless set — keeping older clients byte-identical
2198/// (the protocol's forward-compatibility convention).
2199#[allow(clippy::trivially_copy_pass_by_ref)]
2200fn is_false(b: &bool) -> bool {
2201    !*b
2202}
2203
2204/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
2205/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
2206/// daemon restart within the lease window keep the *remaining* time rather than
2207/// resetting the 15-minute clock; an already-expired entry is dropped on load.
2208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2209struct PollingLease {
2210    repo: String,
2211    expires_at: DateTime<Utc>,
2212}
2213
2214/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
2215/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
2216/// absence means not-polled (the default-off model) — so the file stays small (a
2217/// handful of active repos out of many open). `#[serde(default)]` so an
2218/// empty/older file decodes to "nothing enabled".
2219#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2220struct PollingPrefs {
2221    #[serde(default)]
2222    enabled: Vec<PollingLease>,
2223}
2224
2225/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
2226/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
2227/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
2228fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
2229    if let Some(parent) = path.parent() {
2230        crate::daemon::paths::ensure_dir_0700(parent)?;
2231    }
2232    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
2233    crate::daemon::paths::write_file_0600(path, &json)
2234}
2235
2236// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
2237
2238/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
2239///
2240/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
2241/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
2242/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
2243/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
2244/// compared against the worktree's current HEAD via
2245/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
2246/// `head_oid` would render every restored badge stale — so it carries the field
2247/// explicitly under a stable snake_case name.
2248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2249struct PersistedBadge {
2250    number: u64,
2251    is_draft: bool,
2252    checks: PrCheckState,
2253    url: String,
2254    head_oid: String,
2255}
2256
2257/// A persisted resolution — the disk twin of
2258/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
2259/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
2260/// `{ "Pr": { … } }`.
2261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2262enum PersistedResolution {
2263    Pr(PersistedBadge),
2264    NoPr,
2265}
2266
2267/// One persisted cache entry: which target, and the verdict last resolved for it.
2268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2269struct PersistedEntry {
2270    target: PrTarget,
2271    resolution: PersistedResolution,
2272}
2273
2274/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
2275/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
2276/// immediate re-poll" from "a target was added or a branch pushed while we were
2277/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
2278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2279struct PersistedWatch {
2280    target: PrTarget,
2281    #[serde(default, skip_serializing_if = "Option::is_none")]
2282    upstream_sha: Option<String>,
2283}
2284
2285/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
2286/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
2287/// so an empty/older/partial file decodes to "nothing restored" rather than
2288/// failing the load — best-effort, exactly like [`PollingPrefs`].
2289#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2290struct PrCachePrefs {
2291    #[serde(default)]
2292    entries: Vec<PersistedEntry>,
2293    #[serde(default)]
2294    watched: Vec<PersistedWatch>,
2295    #[serde(default, skip_serializing_if = "Option::is_none")]
2296    polled_at: Option<DateTime<Utc>>,
2297}
2298
2299impl PersistedResolution {
2300    /// The disk form of a live resolution.
2301    fn from_resolution(r: &PrResolution) -> Self {
2302        match r {
2303            PrResolution::Pr(b) => Self::Pr(PersistedBadge {
2304                number: b.number,
2305                is_draft: b.is_draft,
2306                checks: b.checks,
2307                url: b.url.clone(),
2308                head_oid: b.head_oid.clone(),
2309            }),
2310            PrResolution::NoPr => Self::NoPr,
2311        }
2312    }
2313
2314    /// The live form of a restored resolution.
2315    fn into_resolution(self) -> PrResolution {
2316        match self {
2317            Self::Pr(b) => PrResolution::Pr(PrBadge {
2318                number: b.number,
2319                is_draft: b.is_draft,
2320                checks: b.checks,
2321                url: b.url,
2322                head_oid: b.head_oid,
2323            }),
2324            Self::NoPr => PrResolution::NoPr,
2325        }
2326    }
2327}
2328
2329/// Assembles the on-disk cache from the live cache entries, the watch they were
2330/// resolved for, and the poll time.
2331fn pr_cache_prefs_from(
2332    entries: Vec<(PrTarget, PrResolution)>,
2333    watched: &[PrWatch],
2334    polled_at: DateTime<Utc>,
2335) -> PrCachePrefs {
2336    let mut entries: Vec<PersistedEntry> = entries
2337        .into_iter()
2338        .map(|(target, resolution)| PersistedEntry {
2339            target,
2340            resolution: PersistedResolution::from_resolution(&resolution),
2341        })
2342        .collect();
2343    // Stable on-disk order so the file does not churn on rewrite from `HashMap`
2344    // iteration order alone (the same reason `PollingPrefs` sorts).
2345    entries.sort_by(|a, b| a.target.cmp(&b.target));
2346    let mut watched: Vec<PersistedWatch> = watched
2347        .iter()
2348        .map(|w| PersistedWatch {
2349            target: w.target.clone(),
2350            upstream_sha: w.upstream_sha.clone(),
2351        })
2352        .collect();
2353    watched.sort_by(|a, b| a.target.cmp(&b.target));
2354    PrCachePrefs {
2355        entries,
2356        watched,
2357        polled_at: Some(polled_at),
2358    }
2359}
2360
2361/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
2362/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
2363fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
2364    if let Some(parent) = path.parent() {
2365        crate::daemon::paths::ensure_dir_0700(parent)?;
2366    }
2367    let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
2368    crate::daemon::paths::write_file_0600(path, &json)
2369}
2370
2371/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
2372/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
2373/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
2374/// in-memory cache is authoritative for the running daemon and losing the warm
2375/// start only costs one extra poll after the next restart.
2376fn persist_pr_cache(
2377    path: &Path,
2378    pr_cache: &PrStatusCache,
2379    watched: &[PrWatch],
2380    polled_at: DateTime<Utc>,
2381) {
2382    let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
2383    if let Err(err) = write_pr_cache(path, &prefs) {
2384        let at = path.display();
2385        tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
2386    }
2387}
2388
2389/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
2390/// the watch set the previous daemon last resolved and when it last polled. The
2391/// poller seeds its loop from this so a restart within the backoff window skips
2392/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
2393/// call to re-derive what the `0600` file already carries.
2394#[derive(Debug, Clone)]
2395struct PrWarmStart {
2396    /// The `(target, upstream_sha)` set the persisted verdicts describe.
2397    watched: Vec<PrWatch>,
2398    /// When those verdicts were resolved, used to age the warm start against the
2399    /// backoff (a stale-enough file just re-polls).
2400    polled_at: DateTime<Utc>,
2401}
2402
2403// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
2404
2405/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
2406/// fetched, for TTL expiry.
2407#[derive(Debug, Clone)]
2408struct OpenPrEntry {
2409    at: Instant,
2410    prs: Vec<Value>,
2411}
2412
2413/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
2414///
2415/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
2416/// from the daemon means N windows asking about one repo dedupe to a single counted
2417/// `gh pr list` within the TTL, instead of each window shelling its own (the
2418/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
2419/// the access pattern is a manual action (or a brief post-enable transient), so two
2420/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
2421/// and harmless, while the common repeat-within-TTL is served for free. The lock is
2422/// never held across an `.await`.
2423#[derive(Debug)]
2424struct OpenPrCache {
2425    entries: Mutex<HashMap<String, OpenPrEntry>>,
2426    ttl: Duration,
2427}
2428
2429impl OpenPrCache {
2430    fn new(ttl: Duration) -> Self {
2431        Self {
2432            entries: Mutex::new(HashMap::new()),
2433            ttl,
2434        }
2435    }
2436
2437    /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
2438    /// `None` (a miss that the caller resolves with a fresh `gh`).
2439    fn fresh(&self, key: &str) -> Option<Vec<Value>> {
2440        self.entries
2441            .lock()
2442            .unwrap_or_else(PoisonError::into_inner)
2443            .get(key)
2444            .filter(|e| e.at.elapsed() < self.ttl)
2445            .map(|e| e.prs.clone())
2446    }
2447
2448    /// Records a freshly-fetched PR list for `key`.
2449    fn store(&self, key: String, prs: Vec<Value>) {
2450        self.entries
2451            .lock()
2452            .unwrap_or_else(PoisonError::into_inner)
2453            .insert(
2454                key,
2455                OpenPrEntry {
2456                    at: Instant::now(),
2457                    prs,
2458                },
2459            );
2460    }
2461}
2462
2463/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
2464/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
2465/// call on a blocking thread, never an async worker. The array is forwarded to the
2466/// extension verbatim, which parses it into its `PullRequest` shape.
2467fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
2468    let output = crate::github_metrics::run_gh(
2469        bin,
2470        [
2471            "pr",
2472            "list",
2473            "--repo",
2474            slug,
2475            "--state",
2476            "open",
2477            "--json",
2478            OPEN_PR_JSON_FIELDS,
2479            "--limit",
2480            OPEN_PR_LIST_LIMIT,
2481        ],
2482        "pr list",
2483        None,
2484    )
2485    .with_context(|| {
2486        format!(
2487            "failed to run {} (is the GitHub CLI installed?)",
2488            bin.display()
2489        )
2490    })?;
2491    if !output.status.success() {
2492        let stderr = String::from_utf8_lossy(&output.stderr);
2493        bail!("gh pr list failed: {}", stderr.trim());
2494    }
2495    match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
2496        Value::Array(arr) => Ok(arr),
2497        _ => bail!("gh pr list did not return a JSON array"),
2498    }
2499}
2500
2501/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
2502/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
2503/// op and the tray menu, both bounded to the (few) open windows, where the extra
2504/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
2505/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
2506fn git_status(folder: &Path) -> GitStatus {
2507    git_status_impl(folder, true)
2508}
2509
2510/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
2511/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
2512/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
2513/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
2514/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
2515/// fields stay `None`, so they are omitted on the wire exactly as for a branch
2516/// with no upstream.
2517fn git_status_cheap(folder: &Path) -> GitStatus {
2518    git_status_impl(folder, false)
2519}
2520
2521/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
2522/// repository that contains `folder` — so a subdirectory or a linked worktree both
2523/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
2524/// divergence. Every failure mode degrades to an empty status rather than
2525/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
2526fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
2527    let Ok(repo) = Repository::discover(folder) else {
2528        return GitStatus::default();
2529    };
2530    // Repo identity applies even when HEAD is unborn or detached, so a worktree
2531    // still names its parent repo (and is flagged as a worktree) in those states.
2532    // The in-progress operation is read here too, for the same reason: a worktree
2533    // mid-rebase has a *detached* HEAD, so reading it any later would miss the one
2534    // state the cue exists to show.
2535    let base = GitStatus {
2536        main_repo: main_repo_name(repo.commondir()),
2537        is_worktree: repo.is_worktree(),
2538        operation: operation_slug(repo.state()),
2539        ..GitStatus::default()
2540    };
2541    let Ok(head) = repo.head() else {
2542        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
2543        return base;
2544    };
2545    // Resolved here — before the branch filter below, so a detached HEAD still
2546    // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
2547    // a refs read: no revwalk and no object lookup, so unlike the divergence walk
2548    // it is cheap enough for the streamed snapshot's every-worktree-every-tick
2549    // rebuild (#1306's bar).
2550    let base = GitStatus {
2551        head_sha: head.target().map(|oid| oid.to_string()),
2552        ..base
2553    };
2554    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
2555    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
2556    // name — degrades to no branch through this one path.
2557    let Some(name) = head
2558        .shorthand()
2559        .ok()
2560        .filter(|_| head.is_branch())
2561        .map(str::to_string)
2562    else {
2563        return base;
2564    };
2565    // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
2566    // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
2567    // below costs the cheap path nothing, and is what gives it a handle to
2568    // resolve the upstream from.
2569    let branch = git2::Branch::wrap(head);
2570    // Unlike the divergence walk, this rides both paths: it is what makes a push
2571    // a visible delta (#1344).
2572    let upstream_sha = upstream_target(&branch);
2573    // The divergence walk is the dominant per-worktree cost, so the cheap path
2574    // skips it and leaves ahead/behind absent.
2575    let (ahead, behind) = if with_ahead_behind {
2576        match upstream_ahead_behind(&repo, &branch) {
2577            Some((ahead, behind)) => (Some(ahead), Some(behind)),
2578            None => (None, None),
2579        }
2580    } else {
2581        (None, None)
2582    };
2583    GitStatus {
2584        branch: Some(name),
2585        upstream_sha,
2586        ahead,
2587        behind,
2588        ..base
2589    }
2590}
2591
2592/// The stable kebab-case slug for a repository's in-progress operation, or `None`
2593/// when it is [`RepositoryState::Clean`] (#1415).
2594///
2595/// The three rebase flavours libgit2 distinguishes (`Rebase`, `RebaseInteractive`,
2596/// `RebaseMerge`) all collapse to `rebase-interactive` or `rebase`, because the
2597/// distinction is an implementation detail of how git is driving the rebase and
2598/// says nothing a user acting on the row would do differently. The `*Sequence`
2599/// variants likewise fold into their singular form. Everything a client does not
2600/// recognise still renders as "some operation in progress", which is the useful
2601/// floor.
2602fn operation_slug(state: RepositoryState) -> Option<String> {
2603    let slug = match state {
2604        RepositoryState::Clean => return None,
2605        RepositoryState::Merge => "merge",
2606        RepositoryState::Revert | RepositoryState::RevertSequence => "revert",
2607        RepositoryState::CherryPick | RepositoryState::CherryPickSequence => "cherry-pick",
2608        RepositoryState::Bisect => "bisect",
2609        RepositoryState::Rebase | RepositoryState::RebaseMerge => "rebase",
2610        RepositoryState::RebaseInteractive => "rebase-interactive",
2611        RepositoryState::ApplyMailbox | RepositoryState::ApplyMailboxOrRebase => "apply-mailbox",
2612    };
2613    Some(slug.to_string())
2614}
2615
2616/// The commit `branch`'s configured upstream ref points at, or `None` when it
2617/// tracks no upstream (or the ref is unresolvable).
2618///
2619/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
2620/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
2621/// refs read, but still **no revwalk and no object lookup**, which is the bar
2622/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
2623/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
2624/// same OID, so it is proven reachable.
2625fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
2626    Some(branch.upstream().ok()?.get().target()?.to_string())
2627}
2628
2629/// Both lazily-fetched divergences of the branch `head` names, computed on demand
2630/// for the `ahead-behind` op (#1306). Mirrors the branch resolution in
2631/// [`git_status_impl`] but does **only** the upstream walks [`git_status_cheap`]
2632/// omits. Both are absent when `head` is detached or unborn (`None`) — every case
2633/// the tree renders without a sync indicator.
2634///
2635/// `shallow` is the repository's, so it is reported whatever HEAD is: a client
2636/// that memoizes by commit ids needs to know the clone is shallow even for a row
2637/// that has no counts (#2120).
2638///
2639/// `head` is the caller's to resolve, because only the caller knows which HEAD
2640/// is meant: [`folder_divergence`] reads the one of the repository it opened,
2641/// while [`shared_repo`] reads a linked worktree's HEAD through the main
2642/// repository (#2121). Everything below reads refs and config that every worktree
2643/// of a repository shares, so `repo` need not be rooted at the worktree.
2644fn head_divergence(repo: &Repository, head: Option<git2::Reference<'_>>) -> Divergence {
2645    let shallow = divergence::is_shallow(repo);
2646    let Some(head) = head.filter(git2::Reference::is_branch) else {
2647        return Divergence {
2648            shallow,
2649            ..Divergence::default()
2650        };
2651    };
2652    let branch = git2::Branch::wrap(head);
2653    Divergence {
2654        ahead_behind: upstream_ahead_behind(repo, &branch),
2655        main_behind: repo_main_behind(repo, &branch),
2656        shallow,
2657    }
2658}
2659
2660/// Commits `branch` is behind the repository's remote default branch
2661/// (`origin/<main>`), computed on demand for the lazy `ahead-behind` op (#1457).
2662/// Resolves the default branch the same **local-only, no-fetch** way
2663/// `worktree_rebase::resolve_onto` resolves its `--onto` default — via
2664/// [`RemoteInfo::detect_main_branch_local`] — but, unlike that resolver, never
2665/// falls back to a hardcoded `"main"`: this is a passive signal, so an
2666/// unresolvable default branch means silence (`None`) rather than a guess.
2667///
2668/// `None` when: no default branch is locally resolvable, or the branch's own
2669/// upstream **is** already that default branch — in which case
2670/// [`upstream_ahead_behind`]'s `behind` already reports this exact divergence, so
2671/// repeating it here would just duplicate the existing sync count.
2672fn repo_main_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<usize> {
2673    let (onto_ref, onto_oid) = default_branch_tip(repo)?;
2674
2675    // Skip when the branch's own upstream already IS the resolved default
2676    // branch (the common checked-out-main/master case) — compared by ref name
2677    // so a fork's upstream on a *different* remote (e.g. `upstream/main`) is
2678    // never mistaken for it.
2679    if let Ok(upstream) = branch.upstream() {
2680        if upstream.get().name() == Ok(onto_ref.as_str()) {
2681            return None;
2682        }
2683    }
2684
2685    let head_oid = branch.get().target()?;
2686    let (_ahead, behind) = divergence::graph_ahead_behind(repo, head_oid, onto_oid)?;
2687    Some(behind)
2688}
2689
2690/// The repository's remote default branch as `(ref name, tip commit)`, resolved
2691/// local-only like [`repo_main_behind`] has always done it, or `None` when no
2692/// default branch is locally resolvable (#2120).
2693///
2694/// The one resolver for both consumers: [`repo_main_behind`] walks against the tip,
2695/// and the streamed snapshot publishes it as [`TreeRepo::main_sha`]. Sharing it is
2696/// what keeps the published tip and the answer computed from it from drifting — a
2697/// client that memoizes `main_behind` by `main_sha` is only exact because they are
2698/// the same ref. Reads refs only (no revwalk, no object lookup), the bar #1306 set
2699/// for the every-tick snapshot rebuild.
2700fn default_branch_tip(repo: &Repository) -> Option<(String, git2::Oid)> {
2701    let remote = "origin";
2702    let default_branch = RemoteInfo::detect_main_branch_local(repo, remote)?;
2703    let onto_ref = format!("refs/remotes/{remote}/{default_branch}");
2704    let oid = repo
2705        .find_reference(&onto_ref)
2706        .ok()?
2707        .resolve()
2708        .ok()?
2709        .target()?;
2710    Some((onto_ref, oid))
2711}
2712
2713/// Both lazily-fetched divergences of `folder`, from a single repository open
2714/// rooted at `folder` itself (#2111).
2715///
2716/// This is the reference implementation of what the `ahead-behind` op reports for
2717/// a worktree. The op itself goes through [`folder_divergence_shared`], which
2718/// answers from a repository handle shared with the repo's other worktrees and
2719/// falls back here whenever it cannot answer exactly (#2121).
2720fn folder_divergence(folder: &Path) -> Divergence {
2721    let Ok(repo) = Repository::discover(folder) else {
2722        return Divergence::default();
2723    };
2724    head_divergence(&repo, repo.head().ok())
2725}
2726
2727/// [`folder_divergence`] answered from `pool`'s shared repository handles, so a
2728/// batch over many linked worktrees of one repo opens that repo once per
2729/// concurrent computation instead of once per worktree (#2121). Any input the
2730/// shared path cannot answer exactly falls back to [`folder_divergence`], so the
2731/// result is always the one that function would give.
2732fn folder_divergence_shared(pool: &shared_repo::RepoPool, folder: &Path) -> Divergence {
2733    shared_repo::divergence(pool, folder).unwrap_or_else(|| folder_divergence(folder))
2734}
2735
2736/// The main repository's directory name from git's common dir. For the usual
2737/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
2738/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
2739/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
2740/// no name can be derived.
2741fn main_repo_name(commondir: &Path) -> Option<String> {
2742    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
2743    if file_name == ".git" {
2744        // Normal layout: the repo is the directory that contains `.git`.
2745        commondir
2746            .parent()
2747            .and_then(Path::file_name)
2748            .map(|n| n.to_string_lossy().into_owned())
2749    } else {
2750        // A bare repo: use its own directory name, without any `.git` suffix.
2751        Some(
2752            file_name
2753                .strip_suffix(".git")
2754                .unwrap_or(&file_name)
2755                .to_string(),
2756        )
2757    }
2758}
2759
2760/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2761/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2762fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2763    let upstream = branch.upstream().ok()?;
2764    let local_oid = branch.get().target()?;
2765    let upstream_oid = upstream.get().target()?;
2766    divergence::graph_ahead_behind(repo, local_oid, upstream_oid)
2767}
2768
2769/// The wire shape of an enriched window: the stored entry fields plus the
2770/// daemon-computed git state, flattened into one JSON object. Serializing
2771/// through a single struct (rather than mutating a `Value`) keeps every present
2772/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2773/// the absent git fields — no manual per-field insertion.
2774#[derive(Serialize)]
2775struct EnrichedEntry<'a> {
2776    #[serde(flatten)]
2777    entry: &'a WindowEntry,
2778    #[serde(flatten)]
2779    git: GitStatus,
2780}
2781
2782/// Serializes a registry entry and folds in the live [`git_status`] of its
2783/// primary (first) folder, producing the JSON object served on the wire
2784/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2785/// enriched — it is the one the table shows and the "focus" action opens.
2786fn enriched_entry(entry: &WindowEntry) -> Value {
2787    let git = entry
2788        .folders
2789        .first()
2790        .map(|folder| git_status(folder))
2791        .unwrap_or_default();
2792    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2793}
2794
2795/// Enriches a batch of entries with their git state on a blocking thread, since
2796/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2797/// handler. A join failure degrades to an empty list rather than erroring.
2798async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2799    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2800        .await
2801        .unwrap_or_default()
2802}
2803
2804// --- Repo/worktree tree (#1265) ----------------------------------------------
2805
2806/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2807/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2808/// non-GitHub (or remote-less) repo omits it.
2809#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2810struct GithubIdentity {
2811    /// The repository owner (user or org) — the first path segment.
2812    owner: String,
2813    /// The repository name, with any `.git` suffix stripped.
2814    name: String,
2815}
2816
2817/// One worktree of a repository in the `tree` payload: its path, live git state,
2818/// whether it is the main working tree, and whether a VS Code window currently
2819/// has it open (with that window's key, for the focus action). Optional git
2820/// fields degrade independently, exactly like [`GitStatus`].
2821///
2822/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2823/// the dominant per-worktree cost when computed eagerly for every worktree on
2824/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2825/// worktrees a client actually shows (#1306).
2826///
2827/// The two **OIDs** the divergence is computed from — `head_sha` and
2828/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2829/// refs read rather than a commit-graph walk, and between them they are what makes
2830/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2831/// re-ask for the counts it left behind.
2832#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2833struct TreeWorktree {
2834    /// Absolute path to the worktree's working directory.
2835    path: String,
2836    /// The checked-out branch, or `None` when detached or unborn.
2837    #[serde(skip_serializing_if = "Option::is_none")]
2838    branch: Option<String>,
2839    /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2840    /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2841    /// commit a visible delta, so a push re-renders instead of being dropped by the
2842    /// server's snapshot diff (#1337).
2843    #[serde(skip_serializing_if = "Option::is_none")]
2844    head_sha: Option<String>,
2845    /// The commit the branch's upstream ref points at, or `None` without an
2846    /// upstream. The push counterpart of `head_sha`: a push moves only
2847    /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2848    /// making the snapshot a real delta the server's diff cannot drop, which is
2849    /// what re-fetches the lazy ahead/behind (#1344).
2850    #[serde(skip_serializing_if = "Option::is_none")]
2851    upstream_sha: Option<String>,
2852    /// Whether this is the repository's main working tree (vs a linked worktree).
2853    is_main: bool,
2854    /// Whether a live VS Code window currently has this worktree open.
2855    open: bool,
2856    /// The open window's registry key, when `open` — the handle a focus action
2857    /// resolves. Absent for a worktree with no open window.
2858    #[serde(skip_serializing_if = "Option::is_none")]
2859    window_key: Option<String>,
2860    /// The open PR whose head is this worktree's branch, with its CI verdict
2861    /// (#1337). Resolved by the daemon's background poller and folded on as the
2862    /// snapshot is built, so every open window sees the same live state without
2863    /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2864    /// with no open PR (see `pr_none`), or until the first poll lands.
2865    #[serde(skip_serializing_if = "Option::is_none")]
2866    pr: Option<PrBadge>,
2867    /// Set when the daemon **checked GitHub and found no open PR** for this
2868    /// worktree's branch — the explicit negative (#1370), mutually exclusive
2869    /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2870    /// non-GitHub worktree, and — crucially — while the branch is simply **not
2871    /// yet resolved** (before the first poll lands, or ever, on a failed one):
2872    /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2873    /// older client stays byte-identical (ADR-0053). Clients use it to keep
2874    /// their degraded per-window `gh pr list` fallback quiet for branches the
2875    /// daemon has already answered for.
2876    #[serde(skip_serializing_if = "is_false")]
2877    pr_none: bool,
2878    /// The multi-step git operation this worktree is mid-way through, when any
2879    /// (#1415) — see [`GitStatus::operation`]. The **durable** half of the rebase
2880    /// cue: a conflict the `rebase` op left in place shows here until it is
2881    /// resolved, across daemon restarts. Omitted for a clean worktree.
2882    #[serde(skip_serializing_if = "Option::is_none")]
2883    operation: Option<String>,
2884    /// Whether the daemon is rebasing this worktree **right now** (#1415) — the
2885    /// **transient** half of the cue, from the registry's in-memory set.
2886    ///
2887    /// Not redundant with `operation`: a rebase that applies cleanly never leaves
2888    /// an on-disk state for `operation` to report, and even one that conflicts
2889    /// only writes it at the moment of collision — so without this a multi-second
2890    /// rebase would render as nothing happening at all. Omitted (false) for the
2891    /// common case, keeping an older client byte-identical.
2892    #[serde(skip_serializing_if = "is_false")]
2893    rebasing: bool,
2894    /// Whether the daemon is pushing this worktree **right now** (#1443) — the
2895    /// `rebasing` twin, from the registry's other in-flight set.
2896    ///
2897    /// Unlike a rebase this cue has **no durable half**: a push writes no on-disk
2898    /// state, so there is nothing for a later snapshot to rediscover and this flag
2899    /// is the whole of it. A *completed* push instead shows up as `upstream_sha`
2900    /// moving, which is why that field rides the snapshot (#1344). Omitted (false)
2901    /// for the common case, keeping an older client byte-identical.
2902    #[serde(skip_serializing_if = "is_false")]
2903    pushing: bool,
2904}
2905
2906/// The registry's two transient in-flight sets, read together into one tree
2907/// snapshot (#1443).
2908///
2909/// Grouped rather than threaded as two parameters through
2910/// [`worktree_entry`]/[`repo_tree`]/[`build_tree`]/[`tree_repos`]: they are always
2911/// read at the same moment, from the same registry, for the same snapshot, and a
2912/// third would otherwise mean a fifth positional `HashSet` at every level.
2913#[derive(Debug, Clone, Default)]
2914struct InFlight {
2915    /// Worktree paths the `rebase` op is executing on (#1415).
2916    rebasing: HashSet<PathBuf>,
2917    /// Worktree paths the `push` op is executing on (#1443).
2918    pushing: HashSet<PathBuf>,
2919}
2920
2921impl InFlight {
2922    /// Reads both sets off the registry. Two short lock acquisitions, neither held
2923    /// across an `.await`; the pair need not be atomic, since each cue is
2924    /// independently true or false of a given row.
2925    fn read(registry: &WorktreesRegistry) -> Self {
2926        Self {
2927            rebasing: registry.rebasing_paths(),
2928            pushing: registry.pushing_paths(),
2929        }
2930    }
2931}
2932
2933/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2934/// derived from the distinct open windows; a repo leaves the tree when its last
2935/// window closes (the open-window-derived model, ADR-0040 / #1264).
2936#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2937struct TreeRepo {
2938    /// The main repository's directory name (see [`main_repo_name`]).
2939    main_repo: String,
2940    /// The GitHub identity of `origin`, when it is a `github.com` remote.
2941    #[serde(skip_serializing_if = "Option::is_none")]
2942    github: Option<GithubIdentity>,
2943    /// Absolute path to the main working tree — the repo's root.
2944    root: String,
2945    /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2946    /// registry's per-repo enable set, which defaults **off**, so it is omitted
2947    /// (false) for the common not-polled repo — keeping older clients
2948    /// byte-identical — and present (`true`) only for a repo the user has
2949    /// explicitly enabled. The extension colours the repo icon green when set and
2950    /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2951    /// on it so a not-polled repo issues zero `gh`.
2952    #[serde(skip_serializing_if = "is_false")]
2953    polling_enabled: bool,
2954    /// The commit the repository's remote default branch (`origin/<default>`)
2955    /// points at, or `None` when no default branch is locally resolvable (#2120).
2956    ///
2957    /// The third input of a worktree's lazy `main_behind` (#1457), alongside its
2958    /// `head_sha`, and the reason it rides the snapshot: a fetch that advances only
2959    /// `origin/main` moves neither `head_sha` nor `upstream_sha`, so without it the
2960    /// snapshot serialised byte-identically, the server's diff dropped the frame and
2961    /// `main_behind` stayed stale until something unrelated re-rendered (the #1344
2962    /// shape, one ref over). It is also what lets a client memoize `ahead-behind`
2963    /// exactly. Per repo, not per worktree, since remote-tracking refs live in the
2964    /// shared common dir. Resolved by [`default_branch_tip`], the same resolver
2965    /// `main_behind` is computed from. Omitted when unresolvable, keeping an older
2966    /// client byte-identical.
2967    #[serde(skip_serializing_if = "Option::is_none")]
2968    main_sha: Option<String>,
2969    /// Every worktree of the repo: the main working tree first, then linked
2970    /// worktrees sorted by path.
2971    worktrees: Vec<TreeWorktree>,
2972}
2973
2974/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2975/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2976/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2977/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2978/// slashes are stripped; anything with an empty or extra path segment is
2979/// rejected (best-effort, never panics).
2980fn github_identity(url: &str) -> Option<GithubIdentity> {
2981    let url = url.trim();
2982    // Reduce every supported form to the `owner/name…` tail after the host.
2983    let rest = [
2984        "https://github.com/",
2985        "http://github.com/",
2986        "ssh://git@github.com/",
2987        "git://github.com/",
2988        "git@github.com:",
2989    ]
2990    .iter()
2991    .find_map(|prefix| url.strip_prefix(prefix))?;
2992    let rest = rest.strip_suffix(".git").unwrap_or(rest);
2993    let rest = rest.trim_end_matches('/');
2994    let mut parts = rest.splitn(2, '/');
2995    let owner = parts.next()?.trim();
2996    let name = parts.next()?.trim();
2997    // A well-formed identity has exactly two non-empty segments.
2998    if owner.is_empty() || name.is_empty() || name.contains('/') {
2999        return None;
3000    }
3001    Some(GithubIdentity {
3002        owner: owner.to_string(),
3003        name: name.to_string(),
3004    })
3005}
3006
3007/// The GitHub identity of `repo`: `origin`'s URL first, else the first
3008/// `github.com` remote found. `None` when no remote is a GitHub remote.
3009fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
3010    if let Ok(origin) = repo.find_remote("origin") {
3011        if let Some(id) = origin.url().ok().and_then(github_identity) {
3012            return Some(id);
3013        }
3014    }
3015    // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
3016    // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
3017    // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
3018    let names = repo.remotes().ok();
3019    names
3020        .iter()
3021        .flat_map(|arr| arr.iter())
3022        .flatten()
3023        .flatten()
3024        .filter_map(|name| repo.find_remote(name).ok())
3025        .find_map(|remote| remote.url().ok().and_then(github_identity))
3026}
3027
3028/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
3029/// falling back to the path as-given when it cannot be canonicalized (e.g. it
3030/// no longer exists) so the join still degrades gracefully.
3031fn canonical(path: &Path) -> PathBuf {
3032    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3033}
3034
3035/// Indexes the open windows by canonicalized workspace-folder path → window key,
3036/// so a worktree path can be joined back to the window (if any) that has it open.
3037/// The first window wins a shared folder; `entries` arrive in a deterministic
3038/// (repo, key) order, so the choice is stable.
3039fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
3040    let mut index = HashMap::new();
3041    for entry in entries {
3042        for folder in &entry.folders {
3043            index
3044                .entry(canonical(folder))
3045                .or_insert_with(|| entry.key.clone());
3046        }
3047    }
3048    index
3049}
3050
3051/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
3052/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
3053/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
3054/// by the caller from the enumeration (main working tree vs linked).
3055fn worktree_entry(
3056    path: &Path,
3057    is_main: bool,
3058    open_index: &HashMap<PathBuf, String>,
3059    in_flight: &InFlight,
3060) -> TreeWorktree {
3061    let status = git_status_cheap(path);
3062    let canonical = canonical(path);
3063    let window_key = open_index.get(&canonical).cloned();
3064    TreeWorktree {
3065        path: path.display().to_string(),
3066        branch: status.branch,
3067        head_sha: status.head_sha,
3068        upstream_sha: status.upstream_sha,
3069        is_main,
3070        open: window_key.is_some(),
3071        window_key,
3072        // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
3073        // identity — known one level up, in `repo_tree`.
3074        pr: None,
3075        pr_none: false,
3076        operation: status.operation,
3077        // Every in-flight cue is joined on the *canonical* path: the registry sets
3078        // are canonicalized by the adapter when an op marks them.
3079        rebasing: in_flight.rebasing.contains(&canonical),
3080        pushing: in_flight.pushing.contains(&canonical),
3081    }
3082}
3083
3084/// Folds the poller's cached PR resolutions onto each worktree of each repo
3085/// (#1337).
3086///
3087/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
3088/// identity, branch) and the identity is only known once the repo is assembled.
3089/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
3090/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
3091/// keeps `pr: None`/`pr_none: false` and renders nothing.
3092///
3093/// A verdict computed for a **different commit** than the worktree has checked out
3094/// is downgraded to pending here rather than shown as-is. That is what makes a push
3095/// invalidate the badge the moment it happens: the cache still holds the previous
3096/// commit's verdict, and this fold — which runs on every snapshot — notices without
3097/// waiting for a poll. Without it the previous head's `✓` stands until the poller
3098/// next runs, which is up to the full backoff.
3099///
3100/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
3101/// when `head_sha` moves: it has no commit to be stale against, and dropping it
3102/// would re-arm every client's `gh` fallback on every local commit. The poller's
3103/// `moved` trigger re-checks the branch within one fast poll anyway.
3104/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
3105/// enable set (#1376).
3106///
3107/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
3108/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
3109/// enabled carries neither the flag nor any badge. Purely a set membership check —
3110/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
3111/// and stays `false`; it never polls anyway.
3112fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
3113    for repo in repos {
3114        if let Some(github) = &repo.github {
3115            repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
3116        }
3117    }
3118}
3119
3120fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
3121    for repo in repos {
3122        // A not-polled repo (#1376) never carries a badge: skip it so a repo the
3123        // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
3124        // the flag, and so the icon greys cross-window on the next pushed snapshot.
3125        if !repo.polling_enabled {
3126            continue;
3127        }
3128        let Some(github) = repo.github.clone() else {
3129            continue;
3130        };
3131        for worktree in &mut repo.worktrees {
3132            let Some(branch) = &worktree.branch else {
3133                continue;
3134            };
3135            match pr_cache.get(&github.owner, &github.name, branch) {
3136                Some(PrResolution::Pr(mut badge)) => {
3137                    if badge.is_stale_for(worktree.head_sha.as_deref()) {
3138                        badge.checks = PrCheckState::Pending;
3139                    }
3140                    worktree.pr = Some(badge);
3141                }
3142                Some(PrResolution::NoPr) => worktree.pr_none = true,
3143                None => {}
3144            }
3145        }
3146    }
3147}
3148
3149/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
3150/// handle discovered from one of its folders. Opens the **main** repo from the
3151/// shared common dir's parent so the main working tree and every linked worktree
3152/// are enumerated regardless of which one seeded the discovery. `None` for a
3153/// bare or otherwise root-less repo (no working tree to show).
3154fn repo_tree(
3155    discovered: &Repository,
3156    open_index: &HashMap<PathBuf, String>,
3157    in_flight: &InFlight,
3158) -> Option<TreeRepo> {
3159    // The common dir (`…/<root>/.git`) is shared by the main checkout and all
3160    // linked worktrees; its parent is the main working tree.
3161    let commondir = canonical(discovered.commondir());
3162    let main_root = commondir.parent()?.to_path_buf();
3163    let main_repo = Repository::open(&main_root).ok()?;
3164
3165    // Main working tree first.
3166    let mut worktrees = vec![worktree_entry(&main_root, true, open_index, in_flight)];
3167    // Then every linked worktree, sorted by path for deterministic output. The
3168    // `StringArray` of names is bound so `iter()` can borrow it (only
3169    // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
3170    // worktree is skipped.
3171    let names = main_repo.worktrees().ok();
3172    let mut linked: Vec<PathBuf> = names
3173        .iter()
3174        .flat_map(|arr| arr.iter())
3175        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3176        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3177        .filter_map(|name| main_repo.find_worktree(name).ok())
3178        .map(|wt| wt.path().to_path_buf())
3179        .collect();
3180    linked.sort();
3181    worktrees.extend(
3182        linked
3183            .iter()
3184            .map(|path| worktree_entry(path, false, open_index, in_flight)),
3185    );
3186
3187    Some(TreeRepo {
3188        main_repo: main_repo_name(&commondir)?,
3189        github: remote_github_identity(&main_repo),
3190        root: main_root.display().to_string(),
3191        // Defaults off; `stamp_polling` sets it from the registry's enable set
3192        // once the repo (and thus its GitHub identity) is assembled.
3193        polling_enabled: false,
3194        main_sha: default_branch_tip(&main_repo).map(|(_, oid)| oid.to_string()),
3195        worktrees,
3196    })
3197}
3198
3199/// Resolves the seed `folders` to their distinct repositories and enumerates
3200/// each repo's worktrees. Dedupes repos by their common dir (shared across a
3201/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
3202/// is not in a git repo is skipped. Pure blocking git I/O — call it via
3203/// [`tree_repos`], never under the registry lock.
3204fn build_tree(
3205    folders: Vec<PathBuf>,
3206    windows: Vec<WindowEntry>,
3207    in_flight: InFlight,
3208) -> Vec<TreeRepo> {
3209    let open_index = open_window_index(&windows);
3210    let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
3211    for folder in &folders {
3212        let Ok(repo) = Repository::discover(folder) else {
3213            continue;
3214        };
3215        let key = canonical(repo.commondir());
3216        if repos.contains_key(&key) {
3217            continue;
3218        }
3219        if let Some(tree) = repo_tree(&repo, &open_index, &in_flight) {
3220            repos.insert(key, tree);
3221        }
3222    }
3223    repos.into_values().collect()
3224}
3225
3226/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
3227/// does synchronous disk I/O and this runs inside the async control-socket
3228/// handler), returning the serialized `repos` array. A join failure degrades to
3229/// an empty list rather than erroring, matching [`enriched_windows`].
3230async fn tree_repos(
3231    folders: Vec<PathBuf>,
3232    windows: Vec<WindowEntry>,
3233    pr_cache: Arc<PrStatusCache>,
3234    enabled_polling: HashSet<String>,
3235    in_flight: InFlight,
3236) -> Vec<Value> {
3237    tokio::task::spawn_blocking(move || {
3238        let mut repos = build_tree(folders, windows, in_flight);
3239        // Stamp per-repo poll state first so `fold_pr_badges` can skip a
3240        // not-polled repo — a disabled repo carries neither the flag nor a badge.
3241        stamp_polling(&mut repos, &enabled_polling);
3242        fold_pr_badges(&mut repos, &pr_cache);
3243        repos
3244            .iter()
3245            .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
3246            .collect()
3247    })
3248    .await
3249    .unwrap_or_default()
3250}
3251
3252// --- Lazy ahead/behind (#1306) -----------------------------------------------
3253
3254/// The wire shape of one worktree's lazily-fetched divergence: `ahead`/`behind`
3255/// (from its own upstream, folded in together or not at all — they come from one
3256/// `graph_ahead_behind` call) and `main_behind` (from the repo's remote default
3257/// branch, #1457), each independently optional. `main_behind` can be present
3258/// when `ahead`/`behind` are absent (no upstream at all) or absent when they are
3259/// present (the branch's own upstream already *is* the default branch).
3260#[derive(Serialize)]
3261struct AheadBehindEntry {
3262    #[serde(skip_serializing_if = "Option::is_none")]
3263    ahead: Option<usize>,
3264    #[serde(skip_serializing_if = "Option::is_none")]
3265    behind: Option<usize>,
3266    #[serde(skip_serializing_if = "Option::is_none")]
3267    main_behind: Option<usize>,
3268    /// Set when the worktree's repository is shallow, so these counts depend on
3269    /// how deep the clone currently is rather than on commit ids alone (#2120) — the
3270    /// one case where an unchanged `head_sha`/`upstream_sha`/`main_sha` does not mean
3271    /// an unchanged answer, which a client memoizing by those ids must not cache.
3272    /// Omitted (false) for the common complete clone, keeping an older client
3273    /// byte-identical.
3274    #[serde(skip_serializing_if = "is_false")]
3275    shallow: bool,
3276}
3277
3278/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
3279/// returning a JSON object keyed by the **requested** path string:
3280/// `{ "<path>": { "ahead"?, "behind"?, "main_behind"? }, … }`. A row is **omitted**
3281/// when neither the upstream divergence nor the main-branch divergence resolves
3282/// (not a repo, detached/unborn HEAD) — the client renders it without a sync
3283/// indicator, exactly as before #1457. A row can otherwise carry any subset of the
3284/// three fields: `main_behind` alone (no upstream, but behind the default branch),
3285/// `ahead`/`behind` alone (upstream *is* the default branch, so `main_behind` is
3286/// skipped), or all three together.
3287///
3288/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
3289/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
3290/// shows (the extension on expand), not for every worktree on every tick.
3291///
3292/// Every window re-asks for the same worktrees on each pushed delta, so the work
3293/// goes through `coordinator` (#2111): concurrent requests for one worktree share a
3294/// single computation, at most a few run at once, and each runs on a blocking
3295/// thread (the walks are disk I/O). A computation that fails degrades to "no
3296/// divergence" for that one worktree, which omits its row, rather than erroring.
3297async fn ahead_behind_results(coordinator: &AheadBehindCoordinator, paths: Vec<PathBuf>) -> Value {
3298    // One pool serves the whole batch (and any batch overlapping it), so the
3299    // worktrees of one repo share a handle instead of each opening their own
3300    // (#2121). It is released when the last of them finishes.
3301    let pool = coordinator.pool();
3302    let divergences = futures::future::join_all(paths.iter().map(|path| {
3303        let pool = Arc::clone(&pool);
3304        coordinator.get_or_compute(path.clone(), move |p| folder_divergence_shared(&pool, &p))
3305    }))
3306    .await;
3307    let mut results = serde_json::Map::new();
3308    for (path, divergence) in paths.iter().zip(divergences) {
3309        let (ahead, behind) = divergence
3310            .ahead_behind
3311            .map_or((None, None), |(a, b)| (Some(a), Some(b)));
3312        let main_behind = divergence.main_behind;
3313        if ahead.is_none() && main_behind.is_none() {
3314            continue;
3315        }
3316        results.insert(
3317            path.display().to_string(),
3318            json!(AheadBehindEntry {
3319                ahead,
3320                behind,
3321                main_behind,
3322                shallow: divergence.shallow,
3323            }),
3324        );
3325    }
3326    Value::Object(results)
3327}
3328
3329// --- Push subscription (#1267) -----------------------------------------------
3330
3331/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
3332/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
3333/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
3334/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
3335/// type only has to (a) relay the registry's change-notify and (b) read the
3336/// tree snapshot on demand.
3337///
3338/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
3339/// is built at most once per tick and fanned out, rather than each stream
3340/// rebuilding the identical tree. This type holds only cheap handles — a clone
3341/// of the shared cache and its own change-notify receiver.
3342struct WorktreesStream {
3343    /// The shared coalescing cache the snapshot is read through, so every
3344    /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
3345    cache: Arc<TreeSnapshotCache>,
3346    /// Wakes on each visible-set change (a `register`, a removing `unregister`,
3347    /// or a mutation-driven reap). A burst coalesces into one wakeup; the
3348    /// server's diff drops any snapshot that ends up identical.
3349    changes: watch::Receiver<u64>,
3350}
3351
3352#[async_trait]
3353impl ServiceStream for WorktreesStream {
3354    async fn changed(&mut self) {
3355        // `watch::Receiver::changed` marks the newest version seen, so a burst of
3356        // bumps collapses into a single wakeup. If every sender is gone (the
3357        // registry — and thus the daemon — is tearing down) it returns `Err`;
3358        // park instead of returning, so this arm can never spin the server's
3359        // `select!` (the tick and shutdown arms still drive teardown).
3360        if self.changes.changed().await.is_err() {
3361            std::future::pending::<()>().await;
3362        }
3363    }
3364
3365    async fn snapshot(&self) -> Value {
3366        // Read through the shared coalescing cache. The value is built by the
3367        // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
3368        // live push agree byte-for-byte — but here it is built once per tick and
3369        // shared across every subscriber rather than rebuilt per stream (#1303).
3370        self.cache.snapshot().await
3371    }
3372}
3373
3374/// A coalescing cache for the global tree snapshot (#1303).
3375///
3376/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
3377/// server re-samples each on its own `STREAM_TICK` and on every registry change
3378/// — so with N windows the *identical* global tree was being built N times per
3379/// tick. This cache collapses that to **one** build: all streams share it, and
3380/// it rebuilds at most once per `ttl` (the stream tick) per registry
3381/// change-generation.
3382///
3383/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
3384/// exactly as before:
3385/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
3386///   still matches — a `register`/`unregister`/toggle bumps it and forces a
3387///   fresh build, so subscribers never see a stale visible set; and
3388/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
3389///   branch switch, new commits), which fires no registry event, still surfaces
3390///   within one tick.
3391///
3392/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
3393/// callers, so a burst of N streams waking on the same tick/change performs one
3394/// build while the rest wait and read the shared result. The one-shot `tree` op
3395/// bypasses this and computes fresh — it is a rare manual refresh, not part of
3396/// the per-tick fan-out.
3397struct TreeSnapshotCache {
3398    /// The registry every snapshot is built from, and whose change-generation
3399    /// gates cache reuse.
3400    registry: Arc<WorktreesRegistry>,
3401    /// PR badges folded onto each worktree as the snapshot is built (#1337).
3402    /// Written by the background poller; read here. A miss simply omits `pr`.
3403    pr_cache: Arc<PrStatusCache>,
3404    /// How long a built snapshot stays fresh before a tick-driven read rebuilds
3405    /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
3406    /// coalesced build runs at most once per tick; tests inject a shorter value.
3407    ttl: Duration,
3408    /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
3409    /// because it is deliberately held across the `.await` of the git
3410    /// enumeration, so concurrent callers serialize onto one build rather than
3411    /// each computing their own.
3412    state: AsyncMutex<Option<CachedTree>>,
3413    /// How many times the tree was actually (re)built — so tests can assert the
3414    /// coalescing collapses an N-stream burst into one build. Cheap and always
3415    /// maintained; only read under `#[cfg(test)]`.
3416    computes: AtomicU64,
3417}
3418
3419/// One cached tree snapshot: the shared value plus the two freshness stamps
3420/// [`TreeSnapshotCache`] checks before reusing it.
3421struct CachedTree {
3422    /// The registry change-generation captured *before* the build, so a change
3423    /// racing the build advances the generation and the next read rebuilds
3424    /// (conservative: it may rebuild once needlessly, but never serves stale).
3425    generation: u64,
3426    /// When the value was built, for the `ttl` staleness check.
3427    computed_at: Instant,
3428    /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
3429    /// subscriber by cloning the `Arc`'s inner value.
3430    value: Arc<Value>,
3431}
3432
3433impl TreeSnapshotCache {
3434    /// Creates a cache over `registry` with the default TTL — the server's
3435    /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
3436    /// build runs at most once per tick.
3437    fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
3438        Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
3439    }
3440
3441    /// Creates a cache with an explicit `ttl`, for tests that need a short (or
3442    /// long) freshness window without waiting a real tick.
3443    fn with_ttl(
3444        registry: Arc<WorktreesRegistry>,
3445        pr_cache: Arc<PrStatusCache>,
3446        ttl: Duration,
3447    ) -> Self {
3448        Self {
3449            registry,
3450            pr_cache,
3451            ttl,
3452            state: AsyncMutex::new(None),
3453            computes: AtomicU64::new(0),
3454        }
3455    }
3456
3457    /// The current tree snapshot, built at most once per `ttl` per registry
3458    /// change-generation and shared across all callers. See the type docs for
3459    /// the freshness and single-flight semantics.
3460    async fn snapshot(&self) -> Value {
3461        // Hold the lock across the whole check-and-build so concurrent callers
3462        // serialize onto one build (single-flight); reading the generation here
3463        // (before the build) means a change racing the build forces the *next*
3464        // read to rebuild rather than serving this now-stale value.
3465        let mut state = self.state.lock().await;
3466        let generation = self.registry.change_generation();
3467        // Reuse the cached value only while it matches the current generation
3468        // *and* is within the TTL; either failing forces a rebuild.
3469        let fresh = state.as_ref().and_then(|cached| {
3470            (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
3471                .then(|| Arc::clone(&cached.value))
3472        });
3473        let value = if let Some(value) = fresh {
3474            value
3475        } else {
3476            let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
3477            self.computes.fetch_add(1, Ordering::Relaxed);
3478            *state = Some(CachedTree {
3479                generation,
3480                computed_at: Instant::now(),
3481                value: Arc::clone(&value),
3482            });
3483            value
3484        };
3485        // Release the lock before the (deeper) clone of the shared value out.
3486        drop(state);
3487        (*value).clone()
3488    }
3489
3490    /// How many times the tree was actually built — the coalescing assertion in
3491    /// tests (N reads within one tick/generation should build once).
3492    #[cfg(test)]
3493    fn compute_count(&self) -> u64 {
3494        self.computes.load(Ordering::Relaxed)
3495    }
3496}
3497
3498/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
3499/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
3500/// (the seed folders to derive repos from, and the live windows to join on) and
3501/// a lock-free read of the toggle, then the git enumeration/enrichment off the
3502/// lock on a blocking thread inside [`tree_repos`].
3503async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
3504    let folders = registry.open_folders();
3505    let windows = registry.list();
3506    let show_closed = registry.show_closed();
3507    let enabled_polling = registry.enabled_polling_repos();
3508    // The transient rebase (#1415) and push (#1443) cues, read here with the other
3509    // cheap registry locks so the git work below deals only in plain data.
3510    let in_flight = InFlight::read(registry);
3511    json!({
3512        "repos": tree_repos(folders, windows, pr_cache, enabled_polling, in_flight).await,
3513        "show_closed": show_closed,
3514    })
3515}
3516
3517/// A short human name for a window: its repo, else its first folder's basename,
3518/// else a placeholder.
3519fn display_name(entry: &WindowEntry) -> String {
3520    if let Some(repo) = &entry.repo {
3521        return repo.clone();
3522    }
3523    if let Some(folder) = entry.folders.first() {
3524        return folder.file_name().map_or_else(
3525            || folder.display().to_string(),
3526            |n| n.to_string_lossy().into_owned(),
3527        );
3528    }
3529    "(no folder)".to_string()
3530}
3531
3532/// Separator between the repo name and branch for a normal working tree.
3533const REPO_SEP: char = '·';
3534/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
3535/// line is distinguishable at a glance from its parent repo's main checkout.
3536const WORKTREE_SEP: char = '⑂';
3537
3538/// The full tray item list for a window set: the "No open windows" placeholder
3539/// when empty, else one line per window via [`window_menu_items`]. Does the git
3540/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
3541/// background refresh task — and inline only as a cold-start fallback in `menu`.
3542fn menu_items_for(
3543    entries: &[WindowEntry],
3544    rate_limit: Option<&RateLimitSnapshot>,
3545) -> Vec<MenuItem> {
3546    let mut items = Vec::new();
3547    // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
3548    // above the windows, so an approaching exhaustion is visible in the tray before
3549    // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
3550    if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
3551        if !label.is_empty() {
3552            items.push(MenuItem::Label(label));
3553            items.push(MenuItem::Separator);
3554        }
3555    }
3556    if entries.is_empty() {
3557        items.push(MenuItem::Label("No open windows".to_string()));
3558    } else {
3559        items.extend(window_menu_items(entries));
3560    }
3561    items
3562}
3563
3564/// Builds the tray items for a non-empty window list: **one clickable line per
3565/// window** whose label carries the live git state and whose click focuses that
3566/// window. A window with no workspace folder has nothing for `code` to open, so
3567/// it stays a non-clickable status line. The labels read each worktree from disk
3568/// (via [`window_label`]) — cheap for a realistic window count and consistent
3569/// with reap-on-read.
3570fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
3571    entries
3572        .iter()
3573        .map(|entry| {
3574            let label = window_label(entry);
3575            if entry.folders.is_empty() {
3576                MenuItem::Label(label)
3577            } else {
3578                MenuItem::Action(MenuAction {
3579                    id: format!("focus:{}", entry.key),
3580                    label,
3581                    enabled: true,
3582                })
3583            }
3584        })
3585        .collect()
3586}
3587
3588/// The tray label for one window: the **main repository** name, then live branch
3589/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
3590/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
3591/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
3592/// that is not a repo falls back to its reported title.
3593fn window_label(entry: &WindowEntry) -> String {
3594    let status = entry
3595        .folders
3596        .first()
3597        .map(|folder| git_status(folder))
3598        .unwrap_or_default();
3599    // Prefer the git-derived main repo so a linked worktree names its parent
3600    // repository rather than its worktree-folder basename.
3601    let name = status
3602        .main_repo
3603        .clone()
3604        .unwrap_or_else(|| display_name(entry));
3605    if let Some(branch) = &status.branch {
3606        let sep = if status.is_worktree {
3607            WORKTREE_SEP
3608        } else {
3609            REPO_SEP
3610        };
3611        return match sync_indicator(status.ahead, status.behind) {
3612            Some(sync) => format!("{name} {sep} {branch} {sync}"),
3613            None => format!("{name} {sep} {branch}"),
3614        };
3615    }
3616    // No git branch (not a repo / detached): fall back to the reported title.
3617    match &entry.title {
3618        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
3619        _ => name,
3620    }
3621}
3622
3623/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
3624/// has no upstream to compare against.
3625fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
3626    match (ahead, behind) {
3627        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
3628        _ => None,
3629    }
3630}
3631
3632/// Well-known absolute locations for the VS Code launcher, tried in order so a
3633/// daemon running under launchd (with a minimal `PATH`) still finds it.
3634const CODE_BINARY_CANDIDATES: &[&str] = &[
3635    "/usr/local/bin/code",
3636    "/opt/homebrew/bin/code",
3637    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
3638    "/usr/bin/code",
3639];
3640
3641/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
3642/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
3643/// with the sessions service's tray "focus" action, which resolves a session to
3644/// its VS Code window folder and opens it through this same guarded launcher.
3645pub(crate) fn focus_window(folder: &Path) -> Result<()> {
3646    focus_window_with(&resolve_code_binary(), folder)
3647}
3648
3649/// Spawns `program` on `folder` after validating the folder. Split out from
3650/// [`focus_window`] so the validation and spawn paths are testable with an
3651/// explicit launcher (no environment or installed-editor dependency).
3652///
3653/// Best-effort and non-blocking: the spawned child is reaped on a detached
3654/// thread so a long-lived daemon does not accumulate zombies one per focus.
3655fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
3656    // The tray path passes an absolute workspace folder, but the socket `open`
3657    // op (#1266) passes an arbitrary client-supplied path, so this guard is a
3658    // real check there, not just an assertion: requiring an absolute path also
3659    // rules out a `-`-leading path being parsed by `code` as a flag.
3660    if !folder.is_absolute() {
3661        bail!(
3662            "refusing to focus a non-absolute folder path: {}",
3663            folder.display()
3664        );
3665    }
3666    if !folder.is_dir() {
3667        bail!("worktree folder no longer exists: {}", folder.display());
3668    }
3669    // Detach the launcher's stdio so its output never interleaves into the
3670    // long-lived daemon's own stdout/stderr (or the test harness's).
3671    let child = Command::new(program)
3672        .arg(folder)
3673        .stdin(Stdio::null())
3674        .stdout(Stdio::null())
3675        .stderr(Stdio::null())
3676        .spawn()
3677        .with_context(|| {
3678            format!(
3679                "failed to launch `{}` to focus {}",
3680                program.display(),
3681                folder.display()
3682            )
3683        })?;
3684    // Reap the child without blocking so it never lingers as a zombie.
3685    std::thread::spawn(move || {
3686        let mut child = child;
3687        let _ = child.wait();
3688    });
3689    Ok(())
3690}
3691
3692/// Resolves the VS Code launcher from the real environment: the
3693/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
3694/// `code` on `PATH`. The pure resolution logic lives in
3695/// [`resolve_code_binary_from`] for testing.
3696fn resolve_code_binary() -> PathBuf {
3697    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
3698}
3699
3700/// Pure launcher resolution: `env_override` wins; otherwise the first existing
3701/// `candidate`; otherwise bare `code`.
3702fn resolve_code_binary_from(
3703    env_override: Option<std::ffi::OsString>,
3704    candidates: &[&str],
3705) -> PathBuf {
3706    if let Some(path) = env_override {
3707        return PathBuf::from(path);
3708    }
3709    for candidate in candidates {
3710        let path = Path::new(candidate);
3711        if path.exists() {
3712            return path.to_path_buf();
3713        }
3714    }
3715    PathBuf::from("code")
3716}
3717
3718// --- Reposition op (#1407) ---------------------------------------------------
3719
3720/// The `reposition` op payload: move every target window onto the invoking
3721/// window's geometry.
3722///
3723/// Keyed by **window key**, not worktree path, because the subject is a *window*:
3724/// the geometry belongs to the OS window, and a path resolves to one only via the
3725/// registry. The CLI (which naturally speaks paths) maps them to keys itself
3726/// before sending.
3727#[derive(Debug, Clone, Deserialize)]
3728struct RepositionRequest {
3729    /// The invoking window's key. Supplies the frame; never moved itself.
3730    reference_key: String,
3731    /// The windows to move. May include `reference_key` — a multi-selection
3732    /// naturally contains the invoking window — which is reported and skipped.
3733    #[serde(default)]
3734    target_keys: Vec<String>,
3735    /// Resolve and report only, writing nothing (`worktrees reposition
3736    /// --dry-run`). The diagnostic surface for title matching.
3737    #[serde(default)]
3738    check: bool,
3739}
3740
3741/// Distils the live registration for `key` into what [`geometry`] matches on.
3742///
3743/// A key with no live window still yields a value, flagged `live: false`, so it
3744/// reports as a per-target `no-window` skip rather than vanishing from the batch —
3745/// a tree row can be a tick stale, and the user needs to see which of their
3746/// selection was ignored.
3747fn registered_window(entries: &[WindowEntry], key: &str) -> geometry::RegisteredWindow {
3748    entries.iter().find(|entry| entry.key == key).map_or_else(
3749        || geometry::RegisteredWindow {
3750            key: key.to_string(),
3751            live: false,
3752            title: None,
3753            pid: None,
3754        },
3755        |entry| geometry::RegisteredWindow {
3756            key: entry.key.clone(),
3757            live: true,
3758            title: entry.title.clone(),
3759            pid: entry.pid,
3760        },
3761    )
3762}
3763
3764/// Renders a [`geometry::RepositionReport`] as the op's reply.
3765///
3766/// `trusted` is a reply **field**, not an error, so the client can branch on the
3767/// missing-permission case as data — offering the user a link to the Accessibility
3768/// settings pane — rather than pattern-matching an error string.
3769fn reposition_reply(report: &geometry::RepositionReport, undoable: bool) -> Value {
3770    let mut reply = json!({
3771        "trusted": report.trusted,
3772        "results": report.results,
3773        "moved": report.moved(),
3774        "skipped": report.skipped(),
3775    });
3776    if let Some(reference) = &report.reference {
3777        reply["reference"] = serde_json::to_value(reference).unwrap_or_else(|_| json!({}));
3778    }
3779    if let Some(blocked) = &report.blocked {
3780        reply["blocked"] = serde_json::to_value(blocked).unwrap_or_else(|_| json!({}));
3781    }
3782    // Omitted unless true, so a client that only reads `results` sees a reply
3783    // byte-identical to one from a daemon without the undo store.
3784    if undoable {
3785        reply["undoable"] = Value::Bool(true);
3786    }
3787    reply
3788}
3789
3790/// Emits the audit line for a `reposition`, so `omni-dev daemon logs` can answer
3791/// "why did that window not move?" from the log alone (the ADR-0049 §6 precedent).
3792/// Sync, like [`log_merge_check`].
3793fn log_reposition(req: &RepositionRequest, report: &geometry::RepositionReport) {
3794    // `phase` is a structured field rather than three message literals, so a log
3795    // filter can select checks from applies without matching on prose.
3796    let phase = if !report.trusted {
3797        "untrusted"
3798    } else if report.blocked.is_some() {
3799        "blocked"
3800    } else if req.check {
3801        "check"
3802    } else {
3803        "apply"
3804    };
3805    tracing::info!(
3806        phase,
3807        reference = req.reference_key.as_str(),
3808        requested = req.target_keys.len(),
3809        blocked = report.blocked.as_ref().map_or("-", |b| b.reason),
3810        moved = report.moved(),
3811        skipped = report.skipped(),
3812        outcomes = outcome_kinds(report).as_str(),
3813        "reposition"
3814    );
3815}
3816
3817/// Emits the audit line for a `reposition-undo`.
3818fn log_reposition_undo(report: &geometry::RepositionReport) {
3819    tracing::info!(
3820        trusted = report.trusted,
3821        restored = report.moved(),
3822        skipped = report.skipped(),
3823        outcomes = outcome_kinds(report).as_str(),
3824        "reposition undo"
3825    );
3826}
3827
3828/// Joins a report's per-target outcome slugs into one compact `a,b,b` field, so a
3829/// batch's verdict rides a single structured log value rather than a `Debug` dump —
3830/// the [`note_kinds`] precedent.
3831fn outcome_kinds(report: &geometry::RepositionReport) -> String {
3832    if report.results.is_empty() {
3833        return "-".to_string();
3834    }
3835    report
3836        .results
3837        .iter()
3838        .map(|r| r.outcome)
3839        .collect::<Vec<_>>()
3840        .join(",")
3841}
3842
3843// --- Reload op (#1417) -------------------------------------------------------
3844
3845/// The `reload` op payload: reload the listed windows.
3846///
3847/// Keyed by **window**, like [`RepositionRequest`] and unlike [`CloseRequest`] —
3848/// a reload acts on a window, and one tree row is one window, whereas a path can
3849/// be open in several. There is no `requester_key`: a client that wants to
3850/// reload itself does so directly rather than waiting a heartbeat for its own
3851/// directive, so the daemon never needs to know who asked.
3852#[derive(Debug, Clone, Deserialize)]
3853struct ReloadRequest {
3854    /// Registry keys of the windows to signal. An empty list is a no-op, not an
3855    /// error: the callers all filter their targets first, and reporting zeros is
3856    /// more useful to a batch client than a failure.
3857    #[serde(default)]
3858    target_keys: Vec<String>,
3859}
3860
3861/// Emits the audit line for a `reload` op. Sync, like the `close` loggers, so it
3862/// is unit-testable off the runtime. Logs counts and the unknown keys only —
3863/// never a path, which this op never sees.
3864fn log_reload(requested: usize, signalled: usize, unknown: &[String]) {
3865    // Formatted before the macro, not inside it: a `tracing` field expression is
3866    // only evaluated when a subscriber is interested, so inlining this would
3867    // leave it unexecuted (and unmeasurable) in any test that installs none.
3868    let unknown = if unknown.is_empty() {
3869        "-".to_string()
3870    } else {
3871        unknown.join(",")
3872    };
3873    tracing::info!(
3874        requested,
3875        signalled,
3876        unknown = %unknown,
3877        "worktrees reload: signalled windows"
3878    );
3879}
3880
3881// --- Close op (#1277) --------------------------------------------------------
3882
3883/// The `close` op payload: close a worktree's window and (for a linked worktree)
3884/// delete it. Symmetric to `open`, but destructive, so it carries the
3885/// two-phase-confirm and self-close routing fields.
3886#[derive(Debug, Clone, Deserialize)]
3887struct CloseRequest {
3888    /// Absolute path of the target worktree's working directory.
3889    path: PathBuf,
3890    /// The requesting window's key, so a self-close (`requester_key` owns the
3891    /// target) removes-then-replies and lets the extension close its own window,
3892    /// rather than waiting on a window that is blocked awaiting this reply.
3893    #[serde(default)]
3894    requester_key: Option<String>,
3895    /// Whether to **delete** the worktree (linked "Close Worktree") rather than
3896    /// only close its window (main "Close Window"). A delete is refused on the
3897    /// main working tree regardless of this flag.
3898    #[serde(default)]
3899    remove: bool,
3900    /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
3901    /// phase-1, side-effect-free safety check; ignored for `remove:false`.
3902    #[serde(default)]
3903    confirmed: bool,
3904}
3905
3906/// One risk or informational note in a [`SafetyReport`]: a machine-readable
3907/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
3908/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
3909/// commits that survive because the branch is kept).
3910#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3911struct Note {
3912    /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
3913    kind: String,
3914    /// A human-readable one-line explanation for the confirm dialog.
3915    detail: String,
3916}
3917
3918impl Note {
3919    fn new(kind: &str, detail: impl Into<String>) -> Self {
3920        Self {
3921            kind: kind.to_string(),
3922            detail: detail.into(),
3923        }
3924    }
3925}
3926
3927/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
3928/// `-`) for a single structured log field. Used by the `close` op's audit lines
3929/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
3930fn note_kinds(notes: &[Note]) -> String {
3931    if notes.is_empty() {
3932        return "-".to_string();
3933    }
3934    notes
3935        .iter()
3936        .map(|n| n.kind.as_str())
3937        .collect::<Vec<_>>()
3938        .join(",")
3939}
3940
3941/// Whether a `close` execute is a **self-close**: the requesting window owns the
3942/// target, so it acts on our `ok:true` reply and never rides the cross-window
3943/// signal. Split out as a pure predicate so the routing decision the audit line
3944/// (#1364) reports is unit-testable.
3945fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
3946    requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
3947}
3948
3949/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
3950/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
3951/// this a failed safety check (a non-git-worktree target) or a panicked blocking
3952/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
3953/// the error unchanged so callers keep using `?`.
3954fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
3955    tracing::error!(
3956        path = %path.display(),
3957        "worktrees close: {phase} failed: {err:#}"
3958    );
3959    err
3960}
3961
3962/// Logs the outcome of a linked-worktree removal and maps it to the `close`
3963/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
3964/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
3965/// the real prune runs behind.
3966///
3967/// The three outcomes are logged distinctly (#1403) so a future "the daemon says
3968/// pruned but the row is still there" is diagnosable from the log alone: an
3969/// actual prune and an already-gone no-op are both INFO (and both reply
3970/// `removed: true` — the row should go either way), but carry different
3971/// `outcome`/message text; a failure is WARN and propagates the error.
3972fn log_and_map_removal(path: &Path, removed: Result<Removal>) -> Result<Value> {
3973    match removed {
3974        Ok(Removal::Pruned) => {
3975            tracing::info!(
3976                path = %path.display(),
3977                outcome = "pruned",
3978                "worktrees close: linked worktree pruned"
3979            );
3980            Ok(json!({ "removed": true }))
3981        }
3982        Ok(Removal::AlreadyGone) => {
3983            tracing::info!(
3984                path = %path.display(),
3985                outcome = "already-gone",
3986                "worktrees close: nothing to prune, worktree already removed"
3987            );
3988            Ok(json!({ "removed": true }))
3989        }
3990        Err(err) => {
3991            tracing::warn!(
3992                path = %path.display(),
3993                outcome = "failed",
3994                "worktrees close: worktree prune failed: {err:#}"
3995            );
3996            Err(err)
3997        }
3998    }
3999}
4000
4001/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
4002/// the owning window key (if any), the open flag, and the deletability verdict
4003/// with the blocking risk kinds. Split out so the audit line is unit-testable off
4004/// the runtime — a `tracing` event fired right after the `git_safety`
4005/// `spawn_blocking` is not reliably captured under the parallel suite.
4006fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
4007    tracing::info!(
4008        path = %path.display(),
4009        window_key = window_key.unwrap_or("-"),
4010        removable = git.removable,
4011        is_main = git.is_main,
4012        open,
4013        risks = %note_kinds(&git.risks),
4014        "worktrees close: safety check"
4015    );
4016}
4017
4018/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
4019/// window key and the routing decision (self-close vs. how many cross-window
4020/// targets are being signalled), logged before the wait so it is auditable even
4021/// if that wait then hangs. Sync so it is unit-testable off the runtime.
4022fn log_executing(
4023    path: &Path,
4024    requester: Option<&str>,
4025    remove: bool,
4026    self_close: bool,
4027    cross_window: usize,
4028) {
4029    tracing::info!(
4030        path = %path.display(),
4031        requester = requester.unwrap_or("-"),
4032        remove,
4033        self_close,
4034        cross_window,
4035        "worktrees close: executing"
4036    );
4037}
4038
4039/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
4040/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
4041/// unit-testable off the runtime.
4042fn log_close_abort(path: &Path, err: &anyhow::Error) {
4043    tracing::warn!(
4044        path = %path.display(),
4045        "worktrees close: aborted — signalled window(s) did not close: {err:#}"
4046    );
4047}
4048
4049/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
4050/// the window is closed and nothing is deleted (#1364). Sync so it is
4051/// unit-testable off the runtime.
4052fn log_window_closed(path: &Path) {
4053    tracing::info!(
4054        path = %path.display(),
4055        "worktrees close: window closed, no removal"
4056    );
4057}
4058
4059/// The phase-1 safety report the extension reads to decide whether to prompt.
4060/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
4061/// entry → show a modal confirm listing them.
4062#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4063struct SafetyReport {
4064    /// Whether the target is a deletable (linked) worktree at all — `false` for
4065    /// the main working tree, which the daemon never removes.
4066    removable: bool,
4067    /// Whether the target is the repository's main working tree.
4068    is_main: bool,
4069    /// Whether a live VS Code window currently has the target open.
4070    open: bool,
4071    /// The owning window's key, when `open` (the first, for the wait/close).
4072    #[serde(skip_serializing_if = "Option::is_none")]
4073    window_key: Option<String>,
4074    /// How many workspace folders the owning window has — so the extension can
4075    /// warn "this window has N folders open; all will close" (failure mode #10).
4076    window_folder_count: usize,
4077    /// Conditions that would lose data on removal; a non-empty list forces a
4078    /// confirm dialog.
4079    risks: Vec<Note>,
4080    /// Non-blocking context shown for awareness (e.g. unpushed commits that
4081    /// survive because the branch is kept).
4082    info: Vec<Note>,
4083}
4084
4085/// The git-only half of the safety check, before the registry's open-window
4086/// facts are folded in. Pure disk I/O; computed on a blocking thread.
4087#[derive(Debug, Clone, PartialEq, Eq)]
4088struct GitSafety {
4089    is_main: bool,
4090    removable: bool,
4091    risks: Vec<Note>,
4092    info: Vec<Note>,
4093}
4094
4095// --- Rebase op (#1415) -------------------------------------------------------
4096
4097/// The `rebase` op payload: batch-rebase worktrees onto their repository's remote
4098/// default branch. Two-phase like [`MergeQueueRequest`], keyed off `confirmed`,
4099/// and likewise a **single batched** op over `paths` — which is what buys the
4100/// fetch-once-per-repository contract (ADR-0055 §2), since the engine can only
4101/// group by repository if it sees the whole selection at once.
4102#[derive(Debug, Clone, Deserialize)]
4103struct RebaseRequest {
4104    /// Absolute paths of the selected worktree folders.
4105    paths: Vec<PathBuf>,
4106    /// The requesting window's key — carried for the audit line, as `close` and
4107    /// `merge-queue` carry theirs.
4108    #[serde(default)]
4109    requester_key: Option<String>,
4110    /// Phase 1: plan and report only, never rebase.
4111    #[serde(default)]
4112    check: bool,
4113    /// Phase 2: rebase the (re-validated) pending worktrees.
4114    #[serde(default)]
4115    confirmed: bool,
4116    /// Leave a conflicting worktree mid-rebase instead of aborting it. The tree
4117    /// view sends `true` — resolving a conflict in place is the point of #1415 —
4118    /// but it stays a client choice, and defaults to the engine's conservative
4119    /// abort so an older or scripted client gets the pre-#1415 behaviour.
4120    #[serde(default)]
4121    keep_conflicts: bool,
4122    /// Stash uncommitted changes around each rebase rather than skipping a dirty
4123    /// worktree. Not surfaced by the tree view; here so a socket client can ask.
4124    #[serde(default)]
4125    autostash: bool,
4126    /// Rebase onto this ref instead of the remote default branch.
4127    #[serde(default)]
4128    onto: Option<String>,
4129}
4130
4131impl RebaseRequest {
4132    /// The engine options this request selects, with `git` already resolved.
4133    fn options(&self, git_bin: PathBuf) -> worktree_rebase::RebaseOptions {
4134        worktree_rebase::RebaseOptions {
4135            onto: self.onto.clone(),
4136            autostash: self.autostash,
4137            // The daemon never uses the engine's own dry-run flag: phase 1 *is*
4138            // the dry run, and it is `plan` (never `execute`) that runs for it.
4139            dry_run: false,
4140            keep_conflicts: self.keep_conflicts,
4141            git_bin: Some(git_bin),
4142        }
4143    }
4144}
4145
4146/// Runs [`worktree_rebase::plan`] on a blocking thread: it shells out to
4147/// `git fetch` once per repository and walks each worktree's object database, so
4148/// it must never run on an async worker.
4149async fn plan_rebase(
4150    selection: &Selection,
4151    opts: &worktree_rebase::RebaseOptions,
4152) -> Result<worktree_rebase::Plan> {
4153    let selection = selection.clone();
4154    let opts = opts.clone();
4155    tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &opts))
4156        .await
4157        .map_err(|e| anyhow!("rebase planning task panicked: {e}"))
4158        .and_then(|inner| inner)
4159}
4160
4161/// Builds a `rebase` reply. Both phases share one shape — the per-repo fetch
4162/// outcomes and the per-worktree results — because phase 1's report and phase 2's
4163/// result differ only in which [`RebaseResult`](worktree_rebase::RebaseResult)
4164/// variants appear, and a client that can render one can render the other.
4165fn rebase_reply(
4166    fetches: &[worktree_rebase::FetchOutcome],
4167    worktrees: &[worktree_rebase::WorktreeOutcome],
4168) -> Value {
4169    json!({ "fetches": fetches, "worktrees": worktrees })
4170}
4171
4172/// Emits the phase-1 audit line for a `rebase` plan (ADR-0049 §6's precedent, as
4173/// applied by [`log_merge_check`]): who asked, how many worktrees were named, and
4174/// how many the classifier found actually pending.
4175fn log_rebase_check(req: &RebaseRequest, plan: &worktree_rebase::Plan) {
4176    let pending = plan
4177        .worktrees
4178        .iter()
4179        .filter(|w| matches!(w.result, worktree_rebase::RebaseResult::WouldRebase { .. }))
4180        .count();
4181    let failed_fetches = plan.fetches.iter().filter(|f| !f.ok).count();
4182    tracing::info!(
4183        requester = req.requester_key.as_deref().unwrap_or("-"),
4184        requested = req.paths.len(),
4185        pending,
4186        fetches = plan.fetches.len(),
4187        failed_fetches,
4188        "rebase check"
4189    );
4190}
4191
4192/// Emits the phase-2 audit line for a `rebase` execute: the history-rewriting
4193/// outcome, counted by kind. A left-in-place conflict is counted separately from
4194/// an aborted one — it is the case that leaves a worktree needing the user.
4195fn log_rebase_execute(req: &RebaseRequest, outcomes: &[worktree_rebase::WorktreeOutcome]) {
4196    use worktree_rebase::RebaseResult;
4197    let mut rebased = 0;
4198    let mut conflicts = 0;
4199    let mut left_in_place = 0;
4200    let mut skipped = 0;
4201    for outcome in outcomes {
4202        match &outcome.result {
4203            RebaseResult::Rebased { .. } => rebased += 1,
4204            RebaseResult::Conflict {
4205                left_in_place: k, ..
4206            } => {
4207                conflicts += 1;
4208                if *k {
4209                    left_in_place += 1;
4210                }
4211            }
4212            RebaseResult::Skipped { .. } | RebaseResult::FetchFailed { .. } => skipped += 1,
4213            RebaseResult::UpToDate | RebaseResult::WouldRebase { .. } => {}
4214        }
4215    }
4216    tracing::info!(
4217        requester = req.requester_key.as_deref().unwrap_or("-"),
4218        requested = req.paths.len(),
4219        rebased,
4220        conflicts,
4221        left_in_place,
4222        skipped,
4223        "rebase execute"
4224    );
4225}
4226
4227// --- Push op (#1443) ---------------------------------------------------------
4228
4229/// The `push` op payload: publish worktrees' branches to their upstreams,
4230/// force-pushing with a lease where history was rewritten. Two-phase like
4231/// [`RebaseRequest`], keyed off `confirmed`, and likewise a **single batched** op
4232/// over `paths` so the reply is one per-worktree summary rather than N independent
4233/// results.
4234///
4235/// Deliberately has **no** force knob. There is no field a client can set to escape
4236/// the lease, and none to reach a remote other than the branch's own upstream —
4237/// both by design (ADR-0061 §2).
4238#[derive(Debug, Clone, Deserialize)]
4239struct PushRequest {
4240    /// Absolute paths of the selected worktree folders.
4241    paths: Vec<PathBuf>,
4242    /// The requesting window's key — carried for the audit line, as `close`,
4243    /// `merge-queue` and `rebase` carry theirs.
4244    #[serde(default)]
4245    requester_key: Option<String>,
4246    /// Phase 1: classify and report only, never push.
4247    #[serde(default)]
4248    check: bool,
4249    /// Phase 2: publish the (re-validated) pending worktrees.
4250    #[serde(default)]
4251    confirmed: bool,
4252}
4253
4254/// Runs [`worktree_push::plan`] on a blocking thread: it walks each worktree's
4255/// object database, so it must never run on an async worker. Unlike
4256/// [`plan_rebase`] it needs no `git` binary — planning a push contacts no remote.
4257async fn plan_push(selection: &Selection) -> Result<worktree_push::Plan> {
4258    let selection = selection.clone();
4259    tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
4260        .await
4261        .map_err(|e| anyhow!("push planning task panicked: {e}"))
4262        .and_then(|inner| inner)
4263}
4264
4265/// Builds a `push` reply. Both phases share one shape — the per-worktree results —
4266/// because phase 1's report and phase 2's result differ only in which
4267/// [`PushResult`](worktree_push::PushResult) variants appear, and a client that can
4268/// render one can render the other.
4269///
4270/// There is no `fetches` field (the one shape difference from `rebase`): a push
4271/// plan contacts no remote, so there is nothing per-repository to report.
4272fn push_reply(worktrees: &[worktree_push::WorktreeOutcome]) -> Value {
4273    json!({ "worktrees": worktrees })
4274}
4275
4276/// Emits the phase-1 audit line for a `push` plan: who asked, how many worktrees
4277/// were named, how many are pending, and — separately — how many would need the
4278/// lease, since that is the interesting half.
4279fn log_push_check(req: &PushRequest, plan: &worktree_push::Plan) {
4280    use worktree_push::PushResult;
4281    let pending = plan
4282        .worktrees
4283        .iter()
4284        .filter(|w| w.result.is_pending())
4285        .count();
4286    let forced = plan
4287        .worktrees
4288        .iter()
4289        .filter(|w| matches!(w.result, PushResult::WouldForce { .. }))
4290        .count();
4291    let skipped = plan
4292        .worktrees
4293        .iter()
4294        .filter(|w| matches!(w.result, PushResult::Skipped { .. }))
4295        .count();
4296    tracing::info!(
4297        requester = req.requester_key.as_deref().unwrap_or("-"),
4298        requested = req.paths.len(),
4299        pending,
4300        forced,
4301        skipped,
4302        "push check"
4303    );
4304}
4305
4306/// Emits the phase-2 audit line for a `push` execute. `forced` and `stale_rejected`
4307/// are broken out deliberately: the first is the count of histories this daemon
4308/// published a rewrite of, and the second the count of times the lease stopped it
4309/// from overwriting work it had not seen.
4310fn log_push_execute(req: &PushRequest, outcomes: &[worktree_push::WorktreeOutcome]) {
4311    use worktree_push::PushResult;
4312    let mut pushed = 0;
4313    let mut forced = 0;
4314    let mut created = 0;
4315    let mut rejected = 0;
4316    let mut stale_rejected = 0;
4317    for outcome in outcomes {
4318        match &outcome.result {
4319            PushResult::Pushed { forced: f } => {
4320                pushed += 1;
4321                if *f {
4322                    forced += 1;
4323                }
4324            }
4325            PushResult::Created => created += 1,
4326            PushResult::Rejected { stale, .. } => {
4327                rejected += 1;
4328                if *stale {
4329                    stale_rejected += 1;
4330                }
4331            }
4332            PushResult::UpToDate
4333            | PushResult::WouldFastForward { .. }
4334            | PushResult::WouldForce { .. }
4335            | PushResult::WouldCreate
4336            | PushResult::Skipped { .. } => {}
4337        }
4338    }
4339    tracing::info!(
4340        requester = req.requester_key.as_deref().unwrap_or("-"),
4341        requested = req.paths.len(),
4342        pushed,
4343        forced,
4344        created,
4345        rejected,
4346        stale_rejected,
4347        "push execute"
4348    );
4349}
4350
4351// --- Merge-queue op (#1401) --------------------------------------------------
4352
4353/// The `merge-queue` op payload: batch-enqueue eligible worktrees' PRs into the
4354/// GitHub merge queue. Two-phase like [`CloseRequest`], keyed off `confirmed`, but
4355/// a **single batched** op over `paths` rather than one op per target.
4356#[derive(Debug, Clone, Deserialize)]
4357struct MergeQueueRequest {
4358    /// Absolute paths of the selected worktree folders.
4359    paths: Vec<PathBuf>,
4360    /// The requesting window's key — carried for parity with `close` and future
4361    /// per-window routing; unused today.
4362    #[serde(default)]
4363    requester_key: Option<String>,
4364    /// Phase 1: report eligibility only, never enqueue.
4365    #[serde(default)]
4366    check: bool,
4367    /// Phase 2: enqueue the (re-validated) eligible PRs.
4368    #[serde(default)]
4369    confirmed: bool,
4370}
4371
4372/// One enqueue-eligible worktree in an [`EligibilityReport`].
4373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4374struct PrRef {
4375    /// The worktree folder.
4376    path: String,
4377    /// The open PR number.
4378    number: u64,
4379    /// The PR's web URL.
4380    url: String,
4381    /// The branch the PR heads.
4382    branch: String,
4383}
4384
4385impl From<&Eligible> for PrRef {
4386    fn from(e: &Eligible) -> Self {
4387        Self {
4388            path: e.path.to_string_lossy().to_string(),
4389            number: e.number,
4390            url: e.url.clone(),
4391            branch: e.branch.clone(),
4392        }
4393    }
4394}
4395
4396/// One skipped worktree: which, and why — a machine `kind` slug plus a
4397/// human-readable `detail`, mirroring [`Note`].
4398#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4399struct Skip {
4400    path: String,
4401    kind: String,
4402    detail: String,
4403}
4404
4405impl Skip {
4406    fn new(path: &Path, kind: &str, detail: impl Into<String>) -> Self {
4407        Self {
4408            path: path.to_string_lossy().to_string(),
4409            kind: kind.to_string(),
4410            detail: detail.into(),
4411        }
4412    }
4413}
4414
4415/// The phase-1 reply: which selected worktrees are enqueue-eligible and which are
4416/// skipped-with-reason. The extension confirms once over the whole set, then sends
4417/// the phase-2 execute.
4418#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4419struct EligibilityReport {
4420    eligible: Vec<PrRef>,
4421    skipped: Vec<Skip>,
4422}
4423
4424/// One PR successfully in the queue after phase 2.
4425#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4426struct QueuedPr {
4427    path: String,
4428    number: u64,
4429    /// True when the PR was already in the queue — an idempotent no-op, reported as
4430    /// success. Omitted (false) on the wire for the common freshly-queued case.
4431    #[serde(skip_serializing_if = "is_false")]
4432    already_queued: bool,
4433}
4434
4435/// One PR the enqueue mutation rejected (merge queue disabled, not mergeable,
4436/// insufficient permissions, …).
4437#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4438struct EnqueueFailure {
4439    path: String,
4440    number: u64,
4441    error: String,
4442}
4443
4444/// The phase-2 reply: the enqueue outcome for the selected worktrees. `skipped` is
4445/// the re-validated skip set (a worktree that became ineligible between phases).
4446#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4447struct EnqueueResult {
4448    queued: Vec<QueuedPr>,
4449    skipped: Vec<Skip>,
4450    failed: Vec<EnqueueFailure>,
4451}
4452
4453/// A worktree that cleared the local (git-only) gates 1–3, carrying what the
4454/// network step needs to resolve its PR.
4455#[derive(Debug)]
4456struct LocalOk {
4457    path: PathBuf,
4458    target: PrTarget,
4459    head_sha: String,
4460}
4461
4462/// A worktree that cleared **every** gate and is ready to enqueue.
4463#[derive(Debug)]
4464struct Eligible {
4465    path: PathBuf,
4466    number: u64,
4467    url: String,
4468    branch: String,
4469    /// The PR's GraphQL node id — the `enqueuePullRequest` input.
4470    pr_id: String,
4471    /// Already in the queue ⇒ phase 2 skips the mutation and reports success.
4472    already_queued: bool,
4473}
4474
4475/// Evaluates the **local** (git-only) merge-queue gates for one worktree — clean
4476/// tree (1), a real commit (2), fully pushed (3) — and resolves the branch's
4477/// [`PrTarget`] for the network step. Pure disk I/O; runs on a blocking thread.
4478/// Returns the first failing gate as a [`Skip`] so an ineligible worktree never
4479/// costs a GitHub call.
4480fn evaluate_local(path: &Path) -> std::result::Result<LocalOk, Skip> {
4481    let Ok(repo) = Repository::discover(path) else {
4482        return Err(Skip::new(path, "not-a-repo", "not a git repository"));
4483    };
4484    // Gate 1: a clean working tree (reusing the `close` safety check's counter).
4485    let (dirty, untracked) = count_dirty_untracked(&repo);
4486    if dirty > 0 {
4487        return Err(Skip::new(
4488            path,
4489            "dirty",
4490            format!("{dirty} modified tracked file(s) — commit or stash first"),
4491        ));
4492    }
4493    if untracked > 0 {
4494        return Err(Skip::new(
4495            path,
4496            "untracked",
4497            format!("{untracked} untracked file(s) — commit, remove, or ignore first"),
4498        ));
4499    }
4500    // Gate 2: a real commit exists (a non-unborn HEAD). The deeper "commits beyond
4501    // base" is proven by the open PR (gate 4) + GitHub's own enqueue validation.
4502    let Ok(head) = repo.head() else {
4503        return Err(Skip::new(
4504            path,
4505            "no-commits",
4506            "the branch has no commits yet",
4507        ));
4508    };
4509    let Some(head_sha) = head.target().map(|oid| oid.to_string()) else {
4510        return Err(Skip::new(
4511            path,
4512            "no-commits",
4513            "HEAD does not resolve to a commit",
4514        ));
4515    };
4516    // A branch HEAD has a UTF-8 shorthand; a detached HEAD has no branch — and so
4517    // no branch PR to enqueue. Read before `Branch::wrap` consumes `head`.
4518    let Some(branch_name) = head
4519        .shorthand()
4520        .ok()
4521        .filter(|_| head.is_branch())
4522        .map(str::to_string)
4523    else {
4524        return Err(Skip::new(
4525            path,
4526            "detached",
4527            "HEAD is detached — no branch to enqueue",
4528        ));
4529    };
4530    let branch = git2::Branch::wrap(head);
4531    // Gate 3: fully pushed — an upstream exists and matches the local head.
4532    let Some(upstream_sha) = upstream_target(&branch) else {
4533        return Err(Skip::new(
4534            path,
4535            "no-upstream",
4536            "the branch tracks no upstream — push it first",
4537        ));
4538    };
4539    if upstream_sha != head_sha {
4540        return Err(Skip::new(
4541            path,
4542            "unpushed",
4543            "local commits are not on the remote yet — push first",
4544        ));
4545    }
4546    // Belt-and-suspenders: even with matching heads, a positive ahead count is
4547    // unpushed work.
4548    if let Some((ahead, _behind)) = upstream_ahead_behind(&repo, &branch) {
4549        if ahead > 0 {
4550            return Err(Skip::new(
4551                path,
4552                "unpushed",
4553                format!("{ahead} unpushed commit(s) — push first"),
4554            ));
4555        }
4556    }
4557    // Gate 4 setup: the branch's GitHub identity, so the network step can resolve
4558    // its PR. A non-github repo can never have a merge-queue PR.
4559    let Some(id) = remote_github_identity(&repo) else {
4560        return Err(Skip::new(
4561            path,
4562            "no-github",
4563            "the repository has no github.com remote",
4564        ));
4565    };
4566    Ok(LocalOk {
4567        path: path.to_path_buf(),
4568        target: PrTarget {
4569            owner: id.owner,
4570            name: id.name,
4571            branch: branch_name,
4572        },
4573        head_sha,
4574    })
4575}
4576
4577/// Whether GitHub's `mergeStateStatus` says the PR cannot merge cleanly. `DIRTY`
4578/// (merge conflicts) and the explicit `CONFLICTING` both block enqueue; other
4579/// states (`BLOCKED` on a required review, `UNKNOWN` still computing, `CLEAN`) do
4580/// not, since the merge queue itself resolves them.
4581fn is_conflicting(state: Option<&str>) -> bool {
4582    matches!(state, Some("CONFLICTING" | "DIRTY"))
4583}
4584
4585/// A human-readable label for a rolled-up CI verdict, for a `checks-failing` skip.
4586fn check_label(state: PrCheckState) -> &'static str {
4587    match state {
4588        PrCheckState::Success => "passing",
4589        PrCheckState::Failure => "failing",
4590        PrCheckState::Pending => "still running",
4591        PrCheckState::None => "not reported",
4592    }
4593}
4594
4595/// Emits the phase-1 audit line for a `merge-queue` check (ADR-0056; the ADR-0049
4596/// §6 precedent): the requesting window key, how many worktrees were requested,
4597/// and the eligible/skipped split. Sync so it is unit-testable off the runtime —
4598/// and so its `tracing` field expressions are exercised under an INFO subscriber.
4599fn log_merge_check(req: &MergeQueueRequest, eligible: usize, skipped: usize) {
4600    tracing::info!(
4601        requester = req.requester_key.as_deref().unwrap_or("-"),
4602        requested = req.paths.len(),
4603        eligible,
4604        skipped,
4605        "merge-queue check"
4606    );
4607}
4608
4609/// Emits the phase-2 audit line for a `merge-queue` enqueue: the requesting window
4610/// key and the queued/failed/skipped counts. Sync, for the same reasons as
4611/// [`log_merge_check`].
4612fn log_merge_enqueue(req: &MergeQueueRequest, queued: usize, failed: usize, skipped: usize) {
4613    tracing::info!(
4614        requester = req.requester_key.as_deref().unwrap_or("-"),
4615        queued,
4616        failed,
4617        skipped,
4618        "merge-queue enqueue"
4619    );
4620}
4621
4622/// Evaluates every merge-queue gate for a batch of worktree paths and partitions
4623/// them into the enqueue-eligible and the skipped-with-reason. **Blocking** — run
4624/// on a blocking thread.
4625///
4626/// Local gates 1–3 run first (per path); only survivors reach GitHub, so a dirty
4627/// or unpushed worktree is skipped with **zero** API calls. The survivors' PRs are
4628/// resolved in **one** batched `gh api graphql` call, then the network gates —
4629/// an open PR (4), not a draft (5), not conflicting (6), CI green (7), and the
4630/// remote head matching the local head — are applied. Shared by both phases: phase
4631/// 2 re-runs it (never trusting a phase-1 result the client sent).
4632fn evaluate_batch(bin: &Path, paths: &[PathBuf]) -> Result<(Vec<Eligible>, Vec<Skip>)> {
4633    let mut skipped = Vec::new();
4634    let mut locals = Vec::new();
4635    for path in paths {
4636        match evaluate_local(path) {
4637            Ok(ok) => locals.push(ok),
4638            Err(skip) => skipped.push(skip),
4639        }
4640    }
4641    if locals.is_empty() {
4642        return Ok((Vec::new(), skipped));
4643    }
4644    let targets: Vec<PrTarget> = locals.iter().map(|l| l.target.clone()).collect();
4645    let resolved = crate::pr_status::resolve_merge_targets(bin, &targets)?;
4646    let mut eligible = Vec::new();
4647    for local in locals {
4648        let Some(info) = resolved.get(&local.target) else {
4649            skipped.push(Skip::new(
4650                &local.path,
4651                "no-pr",
4652                "no open PR heads this branch",
4653            ));
4654            continue;
4655        };
4656        if info.head_oid != local.head_sha {
4657            skipped.push(Skip::new(
4658                &local.path,
4659                "stale",
4660                "the open PR's head differs from the local head — re-check",
4661            ));
4662        } else if info.is_draft {
4663            skipped.push(Skip::new(
4664                &local.path,
4665                "draft",
4666                format!("PR #{} is a draft", info.number),
4667            ));
4668        } else if is_conflicting(info.merge_state.as_deref()) {
4669            skipped.push(Skip::new(
4670                &local.path,
4671                "conflicting",
4672                format!("PR #{} has merge conflicts", info.number),
4673            ));
4674        } else if info.checks != PrCheckState::Success {
4675            skipped.push(Skip::new(
4676                &local.path,
4677                "checks-failing",
4678                format!(
4679                    "PR #{} checks are {}",
4680                    info.number,
4681                    check_label(info.checks)
4682                ),
4683            ));
4684        } else {
4685            eligible.push(Eligible {
4686                path: local.path,
4687                number: info.number,
4688                url: info.url.clone(),
4689                branch: local.target.branch.clone(),
4690                pr_id: info.pr_id.clone(),
4691                already_queued: info.already_queued,
4692            });
4693        }
4694    }
4695    Ok((eligible, skipped))
4696}
4697
4698/// Enqueues each eligible PR into its repo's merge queue, sequentially.
4699/// **Blocking** — run on a blocking thread. An already-queued PR is reported as
4700/// success without a mutation; a GitHub rejection or a failed `gh` invocation
4701/// lands in `failed[]`, so one un-enqueuable PR never sinks the batch.
4702fn enqueue_eligible(bin: &Path, eligible: Vec<Eligible>) -> (Vec<QueuedPr>, Vec<EnqueueFailure>) {
4703    let mut queued = Vec::new();
4704    let mut failed = Vec::new();
4705    for e in eligible {
4706        let path = e.path.to_string_lossy().to_string();
4707        if e.already_queued {
4708            queued.push(QueuedPr {
4709                path,
4710                number: e.number,
4711                already_queued: true,
4712            });
4713            continue;
4714        }
4715        match crate::pr_status::enqueue_pull_request(bin, &e.pr_id) {
4716            Ok(EnqueueOutcome::Queued(_)) => queued.push(QueuedPr {
4717                path,
4718                number: e.number,
4719                already_queued: false,
4720            }),
4721            Ok(EnqueueOutcome::Rejected(msg)) => failed.push(EnqueueFailure {
4722                path,
4723                number: e.number,
4724                error: msg,
4725            }),
4726            Err(err) => failed.push(EnqueueFailure {
4727                path,
4728                number: e.number,
4729                error: format!("{err:#}"),
4730            }),
4731        }
4732    }
4733    (queued, failed)
4734}
4735
4736/// Live windows (key, workspace-folder count) that currently have `path` open,
4737/// matched by canonicalized path so a symlinked or `..`-laden report still
4738/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
4739fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
4740    let target = canonical(path);
4741    entries
4742        .iter()
4743        .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
4744        .map(|e| (e.key.clone(), e.folders.len()))
4745        .collect()
4746}
4747
4748/// How long the execute phase waits for a signalled window to close
4749/// (`unregister`) before giving up. Deliberately generous against the ~10s
4750/// heartbeat interval the close directive rides — a window may have just
4751/// heartbeated, so the directive is only picked up on the *next* one — plus the
4752/// window's own close/save latency. The keyed-push responsiveness upgrade
4753/// (#1277 fast-follow) removes this wait entirely.
4754const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
4755
4756/// How often the execute phase re-checks whether the signalled windows have
4757/// unregistered.
4758const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
4759
4760/// Waits up to `timeout` for every window *other than* `requester` that has
4761/// `path` open to unregister (close), polling the live registry every `poll`.
4762/// A window silent past the registry's TTL (in awake time, so a system sleep
4763/// does not count) is reaped by `list()` and so counts as closed. Returns an
4764/// error naming the still-open windows on timeout, so the caller can surface
4765/// "window did not close" and leave the worktree untouched (failure modes
4766/// #4/#5).
4767async fn await_windows_closed(
4768    registry: &WorktreesRegistry,
4769    path: &Path,
4770    requester: Option<&str>,
4771    timeout: Duration,
4772    poll: Duration,
4773) -> Result<()> {
4774    let deadline = std::time::Instant::now() + timeout;
4775    loop {
4776        // The registry read is cheap CPU, but the path canonicalization in
4777        // `windows_with_path` is disk I/O — do the whole check on a blocking
4778        // thread, never on the async worker.
4779        let entries = registry.list();
4780        let path = path.to_path_buf();
4781        let requester = requester.map(str::to_string);
4782        let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
4783            windows_with_path(&entries, &path)
4784                .into_iter()
4785                .map(|(k, _)| k)
4786                .filter(|k| requester.as_deref() != Some(k))
4787                .collect()
4788        })
4789        .await
4790        .unwrap_or_default();
4791
4792        if remaining.is_empty() {
4793            return Ok(());
4794        }
4795        if std::time::Instant::now() >= deadline {
4796            bail!("window(s) did not close in time: {}", remaining.join(", "));
4797        }
4798        tokio::time::sleep(poll).await;
4799    }
4800}
4801
4802/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
4803/// working tree (never removable) and, for a linked worktree, what a removal
4804/// would lose. Best-effort per-check but the overall open must succeed — a path
4805/// that is not a git worktree is a hard error (we refuse to delete an unknown
4806/// directory). A path that no longer exists is treated as an already-removed
4807/// linked worktree so the idempotent execute path can proceed with no dialog.
4808fn git_safety(path: &Path) -> Result<GitSafety> {
4809    if !path.exists() {
4810        return Ok(GitSafety {
4811            is_main: false,
4812            removable: true,
4813            risks: vec![],
4814            info: vec![Note::new("already-removed", "worktree no longer exists")],
4815        });
4816    }
4817    let repo = Repository::open(path)
4818        .with_context(|| format!("not a git worktree: {}", path.display()))?;
4819    // The one structural fact deletability keys off — never the branch name.
4820    if !repo.is_worktree() {
4821        return Ok(GitSafety {
4822            is_main: true,
4823            removable: false,
4824            risks: vec![],
4825            info: vec![Note::new(
4826                "main-working-tree",
4827                "the repository's main working tree is never deleted",
4828            )],
4829        });
4830    }
4831
4832    let mut risks = Vec::new();
4833    let mut info = Vec::new();
4834
4835    let (dirty, untracked) = count_dirty_untracked(&repo);
4836    if dirty > 0 {
4837        risks.push(Note::new(
4838            "dirty",
4839            format!("{dirty} modified tracked file(s) would be lost"),
4840        ));
4841    }
4842    if untracked > 0 {
4843        risks.push(Note::new(
4844            "untracked",
4845            format!("{untracked} untracked file(s) would be lost"),
4846        ));
4847    }
4848
4849    // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
4850    let state = repo.state();
4851    if state != RepositoryState::Clean {
4852        risks.push(Note::new(
4853            "in-progress",
4854            format!("an in-progress {state:?} operation would be lost"),
4855        ));
4856    }
4857
4858    // Commits reachable only from a detached HEAD are GC'd once the worktree —
4859    // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
4860    // or tag) loses nothing, so it is not flagged.
4861    if repo.head_detached().unwrap_or(false) {
4862        let lost = unreachable_commit_count(&repo).unwrap_or(0);
4863        if lost > 0 {
4864            risks.push(Note::new(
4865                "unreachable-commits",
4866                format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
4867            ));
4868        }
4869    }
4870
4871    // Unpushed commits on a *named* branch survive: removal never deletes the
4872    // branch. Informational only — it must not block or prompt.
4873    if let Some(ahead) = current_branch_ahead(&repo) {
4874        if ahead > 0 {
4875            info.push(Note::new(
4876                "unpushed",
4877                format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
4878            ));
4879        }
4880    }
4881
4882    Ok(GitSafety {
4883        is_main: false,
4884        removable: true,
4885        risks,
4886        info,
4887    })
4888}
4889
4890/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
4891/// staged or unstaged modification (including conflicts and deletions);
4892/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
4893/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
4894/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
4895/// `(0, 0)` rather than sinking the whole safety check.
4896fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
4897    let mut opts = StatusOptions::new();
4898    opts.include_untracked(true)
4899        .recurse_untracked_dirs(true)
4900        .include_ignored(false)
4901        .exclude_submodules(true);
4902    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
4903        return (0, 0);
4904    };
4905    // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
4906    // it is deliberately excluded from this mask).
4907    let tracked = Status::INDEX_NEW
4908        | Status::INDEX_MODIFIED
4909        | Status::INDEX_DELETED
4910        | Status::INDEX_RENAMED
4911        | Status::INDEX_TYPECHANGE
4912        | Status::WT_MODIFIED
4913        | Status::WT_DELETED
4914        | Status::WT_TYPECHANGE
4915        | Status::WT_RENAMED
4916        | Status::CONFLICTED;
4917    let mut dirty = 0;
4918    let mut untracked = 0;
4919    for entry in statuses.iter() {
4920        let s = entry.status();
4921        if s.contains(Status::WT_NEW) {
4922            untracked += 1;
4923        }
4924        if s.intersects(tracked) {
4925            dirty += 1;
4926        }
4927    }
4928    (dirty, untracked)
4929}
4930
4931/// Counts commits reachable from the (detached) HEAD but from no other ref —
4932/// the commits git would garbage-collect once the worktree's HEAD is gone.
4933/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
4934/// skipped (hiding it would hide the very commits we are counting); every real
4935/// branch/tag/remote ref is hidden, so a tip that any branch also points at
4936/// yields `0` (nothing is actually lost).
4937fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
4938    let head_oid = repo.head().ok()?.target()?;
4939    let mut walk = repo.revwalk().ok()?;
4940    walk.push(head_oid).ok()?;
4941    for reference in repo.references().ok()? {
4942        let Ok(reference) = reference else { continue };
4943        // Skip the literal HEAD ref — hiding it would hide the very commits we
4944        // are counting; every real branch/tag/remote ref is hidden below.
4945        if matches!(reference.name(), Ok("HEAD")) {
4946            continue;
4947        }
4948        if let Some(oid) = reference.target() {
4949            let _ = walk.hide(oid);
4950        }
4951    }
4952    Some(walk.flatten().count())
4953}
4954
4955/// Commits the worktree's current branch is ahead of its upstream, or `None`
4956/// when HEAD is detached or the branch tracks no upstream. Reuses
4957/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
4958fn current_branch_ahead(repo: &Repository) -> Option<usize> {
4959    let head = repo.head().ok()?;
4960    if !head.is_branch() {
4961        return None;
4962    }
4963    let branch = git2::Branch::wrap(head);
4964    upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
4965}
4966
4967/// Resolves the linked worktree whose working directory canonicalizes to
4968/// `target` to its registered name in `main_repo`. Errors when `target` is not
4969/// one of the repo's worktrees — the defensive guard against removing a path
4970/// that opened as a worktree but is not enumerated. Split out so that guard is
4971/// unit-testable without corrupting git's worktree admin state.
4972fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
4973    let names = main_repo.worktrees()?;
4974    names
4975        .iter()
4976        .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
4977        .flatten() // Option<&str> → &str (drop non-UTF-8 names)
4978        .find(|name| {
4979            main_repo
4980                .find_worktree(name)
4981                .is_ok_and(|wt| canonical(wt.path()) == target)
4982        })
4983        .map(str::to_string)
4984        .ok_or_else(|| {
4985            anyhow!(
4986                "worktree {} is not registered in {}",
4987                target.display(),
4988                main_repo.path().display()
4989            )
4990        })
4991}
4992
4993/// Backoff delays between recursive-removal retries (#1315). A concurrent
4994/// writer — a just-closed window's language server (Metals/Bloop) or
4995/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
4996/// create a file between our directory scan and its `rmdir`, making the removal
4997/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
4998/// waits longer, giving the winding-down process time to quiesce. Total wait
4999/// ~2.75s across four retries; the window teardown the caller already waited on
5000/// dominates it.
5001const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
5002    Duration::from_millis(250),
5003    Duration::from_millis(500),
5004    Duration::from_secs(1),
5005    Duration::from_secs(1),
5006];
5007
5008/// Whether `e` is the transient "directory re-populated under us" race we retry
5009/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
5010/// denied, read-only filesystem) we must surface immediately. Matches the raw
5011/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
5012/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
5013fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
5014    matches!(
5015        e.raw_os_error(),
5016        Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
5017    )
5018}
5019
5020/// Recursively removes `dir`, retrying on the transient concurrent-writer race
5021/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
5022/// as success. Non-transient errors surface immediately with the original
5023/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
5024/// `spawn_blocking`), so the between-retry `sleep` is fine.
5025fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
5026    remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
5027}
5028
5029/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
5030/// Provoking the real race requires a concurrent writer to lose a timing window,
5031/// so only an injected sequence of errors can drive every branch of the loop —
5032/// exhausting the backoff especially — deterministically and without sleeping out
5033/// the production schedule.
5034fn remove_dir_all_retrying_with(
5035    dir: &Path,
5036    backoff: &[Duration],
5037    mut remove: impl FnMut() -> std::io::Result<()>,
5038) -> Result<()> {
5039    let mut backoff = backoff.iter();
5040    loop {
5041        match remove() {
5042            Ok(()) => return Ok(()),
5043            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
5044            Err(e) => {
5045                if is_transient_rmdir_error(&e) {
5046                    if let Some(delay) = backoff.next() {
5047                        std::thread::sleep(*delay);
5048                        continue;
5049                    }
5050                }
5051                return Err(e).with_context(|| {
5052                    format!("failed to remove worktree directory {}", dir.display())
5053                });
5054            }
5055        }
5056    }
5057}
5058
5059/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
5060/// still points at an admin directory a prior failed removal already deleted.
5061/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
5062/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
5063/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
5064/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
5065/// opens) and a normal checkout has a `.git` *directory*, so this matches
5066/// neither.
5067fn is_orphaned_worktree(path: &Path) -> bool {
5068    // `read_to_string` fails on a `.git` directory (a normal checkout), so only
5069    // a linked worktree's gitlink file gets past here.
5070    let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
5071        return false;
5072    };
5073    let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
5074        return false;
5075    };
5076    let admin = Path::new(admin);
5077    // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
5078    admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
5079}
5080
5081/// The outcome of a linked-worktree removal, so the audit log can tell "actually
5082/// removed something" from "nothing was there" (#1403). Before this the
5083/// working-tree-gone-but-admin-present case returned `Ok(())` and logged a
5084/// `pruned` lie, leaving the row stuck in the tree view.
5085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5086enum Removal {
5087    /// The admin metadata (and possibly the working tree) was actually removed.
5088    Pruned,
5089    /// Nothing was there to remove — a truly already-removed worktree.
5090    AlreadyGone,
5091}
5092
5093/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
5094/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
5095/// directory and the admin metadata. Refuses the main working tree (the
5096/// defensive backstop behind the UI gating) and a locked worktree (surfacing
5097/// "unlock first" rather than forcing past the lock). Idempotent: an
5098/// already-removed path is a success.
5099///
5100/// The working tree is removed **first** (retrying to absorb the
5101/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
5102/// This is deliberately the reverse of libgit2's combined
5103/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
5104/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
5105/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
5106/// is already gone). Doing the directory first means a transient failure leaves
5107/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
5108/// the old ordering is detected and its leftover directory cleaned up directly.
5109///
5110/// A working directory that is *already gone* is **not** blindly treated as a
5111/// no-op: a half-removal from outside the daemon (a manual `rm -rf`, an OS
5112/// cleanup) can leave the main repo's `.git/worktrees/<name>/` admin entry behind
5113/// (git marks it `prunable` and the tree view keeps showing the row). That path
5114/// hands off to [`prune_orphaned_admin`], which locates the owning main repo from
5115/// `windows` (or the path's ancestors) and prunes just that entry; only when no
5116/// repo still tracks the path is it reported [`Removal::AlreadyGone`] (#1403).
5117fn remove_worktree(path: &Path, windows: &[WindowEntry]) -> Result<Removal> {
5118    if !path.exists() {
5119        return prune_orphaned_admin(path, &candidate_main_repos(path, windows));
5120    }
5121    let repo = match Repository::open(path) {
5122        Ok(repo) => repo,
5123        // Admin metadata already gone (a prior failed removal); git no longer
5124        // tracks this path, so no prune applies — just delete the leftover.
5125        Err(_) if is_orphaned_worktree(path) => {
5126            remove_dir_all_retrying(path)?;
5127            return Ok(Removal::Pruned);
5128        }
5129        Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
5130    };
5131    if !repo.is_worktree() {
5132        bail!(
5133            "refusing to delete the main working tree: {}",
5134            path.display()
5135        );
5136    }
5137    // The Worktree handle lives on the *main* repo (the common dir's parent),
5138    // keyed by name; find it by matching the target path.
5139    let commondir = canonical(repo.commondir());
5140    let main_root = commondir
5141        .parent()
5142        .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
5143        .to_path_buf();
5144    // Drop the worktree-scoped handle before we delete its directory.
5145    drop(repo);
5146    let main_repo = Repository::open(&main_root)
5147        .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
5148    let name = worktree_name_for_path(&main_repo, &canonical(path))?;
5149    let worktree = main_repo.find_worktree(&name)?;
5150
5151    // Never silently force past a lock (failure mode #6).
5152    if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5153        let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5154        bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5155    }
5156
5157    // Delete the checked-out directory ourselves, retrying past the
5158    // concurrent-writer race (#1315).
5159    remove_dir_all_retrying(path)?;
5160
5161    // The directory is gone; prune only the admin metadata. working_tree(false)
5162    // keeps git2 from re-attempting (and failing on) the now-absent directory;
5163    // valid(true) prunes even though the worktree was valid; locked stays false,
5164    // so a lock (re-checked above) is never forced.
5165    let mut opts = git2::WorktreePruneOptions::new();
5166    opts.valid(true).working_tree(false);
5167    worktree
5168        .prune(Some(&mut opts))
5169        .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
5170    Ok(Removal::Pruned)
5171}
5172
5173/// The main-repo roots to search when pruning an orphaned worktree whose working
5174/// directory is already gone (#1403). There is no on-disk breadcrumb from the
5175/// vanished working tree back to its repo — the `.git` gitlink lived *inside* the
5176/// deleted directory — so the owner has to be found by enumerating candidates:
5177///
5178/// - **the path's own ancestors**, covering a worktree nested under its repo
5179///   (e.g. `<repo>/.claude/worktrees/<name>`): an existing ancestor that opens as
5180///   the *main* checkout is the owner. `Repository::open` (not `discover`) so only
5181///   a real repo-root ancestor matches, never an intermediate directory.
5182/// - **every main repo the live `windows` resolve to**, covering an external
5183///   worktree that shares no ancestor with its repo. These are the same repos
5184///   whose `worktrees()` enumeration produced the orphaned row, so this is
5185///   guaranteed to include the owner whenever the UI could show a row to close.
5186///
5187/// Deduped, order-preserving (ancestors first).
5188fn candidate_main_repos(path: &Path, windows: &[WindowEntry]) -> Vec<PathBuf> {
5189    let mut roots: Vec<PathBuf> = Vec::new();
5190    let mut push = |root: PathBuf| {
5191        if !roots.contains(&root) {
5192            roots.push(root);
5193        }
5194    };
5195    // Skip `path` itself (gone) via `skip(1)`.
5196    for ancestor in path.ancestors().skip(1) {
5197        if let Ok(repo) = Repository::open(ancestor) {
5198            if !repo.is_worktree() {
5199                if let Some(root) = canonical(repo.commondir()).parent() {
5200                    push(root.to_path_buf());
5201                }
5202            }
5203        }
5204    }
5205    for folder in windows.iter().flat_map(|w| &w.folders) {
5206        if let Ok(repo) = Repository::discover(folder) {
5207            if let Some(root) = canonical(repo.commondir()).parent() {
5208                push(root.to_path_buf());
5209            }
5210        }
5211    }
5212    roots
5213}
5214
5215/// Prunes the leftover `.git/worktrees/<name>/` admin metadata of a worktree
5216/// whose working directory is already gone (#1403). Searches
5217/// `candidate_main_repos` for the main repo that still tracks a worktree
5218/// registered at `path`, prunes just that entry's metadata (`working_tree(false)`
5219/// — the checkout is already gone), and returns [`Removal::Pruned`]. When no
5220/// candidate still tracks the path it is truly already-removed:
5221/// [`Removal::AlreadyGone`]. A locked entry is refused, mirroring
5222/// [`remove_worktree`]'s live path, rather than forced past.
5223fn prune_orphaned_admin(path: &Path, candidate_main_repos: &[PathBuf]) -> Result<Removal> {
5224    let target = canonical(path);
5225    for root in candidate_main_repos {
5226        let Ok(main_repo) = Repository::open(root) else {
5227            continue;
5228        };
5229        // Only the main checkout carries the `.git/worktrees/<name>/` admin dir.
5230        if main_repo.is_worktree() {
5231            continue;
5232        }
5233        // Not the owner (or the entry is already pruned) — keep looking.
5234        let Ok(name) = worktree_name_for_path(&main_repo, &target) else {
5235            continue;
5236        };
5237        let worktree = main_repo.find_worktree(&name)?;
5238        if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
5239            let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
5240            bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
5241        }
5242        let mut opts = git2::WorktreePruneOptions::new();
5243        opts.valid(true).working_tree(false);
5244        worktree.prune(Some(&mut opts)).with_context(|| {
5245            format!(
5246                "failed to prune orphaned worktree metadata for {}",
5247                path.display()
5248            )
5249        })?;
5250        return Ok(Removal::Pruned);
5251    }
5252    Ok(Removal::AlreadyGone)
5253}
5254
5255#[cfg(test)]
5256#[allow(clippy::unwrap_used, clippy::expect_used)]
5257mod tests {
5258    use super::*;
5259    use crate::test_support::shim::{
5260        retry_on_etxtbsy, retry_on_etxtbsy_async, shim_lock, write_exec_script,
5261    };
5262    use std::sync::MutexGuard;
5263
5264    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
5265        json!({
5266            "key": key,
5267            "folders": [folder],
5268            "repo": repo,
5269            "title": format!("{key}-title"),
5270            "pid": 1234,
5271        })
5272    }
5273
5274    /// Pulls the `windows` array out of a `list`/`status` payload.
5275    fn windows_of(payload: &Value) -> &Vec<Value> {
5276        payload
5277            .get("windows")
5278            .and_then(Value::as_array)
5279            .expect("windows array")
5280    }
5281
5282    #[tokio::test]
5283    async fn name_and_unknown_op() {
5284        let svc = WorktreesService::new();
5285        assert_eq!(svc.name(), "worktrees");
5286        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
5287    }
5288
5289    #[tokio::test]
5290    async fn handle_routes_ops_and_shapes_payloads() {
5291        let svc = WorktreesService::new();
5292        // Empty to start.
5293        let payload = svc.handle("list", Value::Null).await.unwrap();
5294        assert_eq!(payload, json!({ "windows": [] }));
5295
5296        // register → { ok: true }, then it shows up in list.
5297        let reply = svc
5298            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5299            .await
5300            .unwrap();
5301        assert_eq!(reply, json!({ "ok": true }));
5302        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
5303        assert_eq!(windows.len(), 1);
5304        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
5305        assert!(windows[0].get("last_seen").is_some());
5306
5307        // heartbeat known/unknown.
5308        let known = svc
5309            .handle("heartbeat", json!({ "key": "w1" }))
5310            .await
5311            .unwrap();
5312        assert_eq!(known, json!({ "known": true }));
5313        let unknown = svc
5314            .handle("heartbeat", json!({ "key": "nope" }))
5315            .await
5316            .unwrap();
5317        assert_eq!(unknown, json!({ "known": false }));
5318
5319        // reload signals a live window and reports one it does not know.
5320        let reloaded = svc
5321            .handle("reload", json!({ "target_keys": ["w1", "nope"] }))
5322            .await
5323            .unwrap();
5324        assert_eq!(
5325            reloaded,
5326            json!({ "requested": 2, "signalled": 1, "unknown": ["nope"] })
5327        );
5328        assert!(svc.registry.take_reload_pending("w1"));
5329
5330        // unregister removes, then repeats as a no-op success.
5331        let gone = svc
5332            .handle("unregister", json!({ "key": "w1" }))
5333            .await
5334            .unwrap();
5335        assert_eq!(gone, json!({ "removed": true }));
5336        let again = svc
5337            .handle("unregister", json!({ "key": "w1" }))
5338            .await
5339            .unwrap();
5340        assert_eq!(again, json!({ "removed": false }));
5341    }
5342
5343    // --- Reposition op (#1407) ------------------------------------------------
5344
5345    /// A window backend over an in-memory window table that **actually applies**
5346    /// writes, so the adapter's own responsibilities — key resolution, the undo
5347    /// store, the reply shape — are testable with no `unsafe`, no real windows, and
5348    /// no Accessibility grant. The planner/matcher itself is covered by
5349    /// `geometry`'s own tests.
5350    ///
5351    /// Applying the writes is what makes a reposition-then-undo round trip mean
5352    /// anything: against a fixed table the restore would find every window already
5353    /// in its wanted position and correctly report `unchanged`.
5354    #[derive(Clone)]
5355    struct StubBackend {
5356        trusted: bool,
5357        /// One application's windows. `Arc` so every clone the factory hands out —
5358        /// and every op in a test — shares the same mutating table.
5359        windows: Arc<Mutex<Vec<geometry::OsWindow>>>,
5360        /// Every frame written, in order.
5361        writes: Arc<Mutex<Vec<geometry::Frame>>>,
5362    }
5363
5364    impl StubBackend {
5365        /// One application (pid 900) with two default-format VS Code windows, and
5366        /// two ext-host pids (11, 12) mapping onto it.
5367        fn new(trusted: bool) -> Self {
5368            let window = |title: &str, x: f64, width: f64| geometry::OsWindow {
5369                title: title.to_string(),
5370                frame: geometry::Frame {
5371                    x,
5372                    y: 0.0,
5373                    width,
5374                    height: 600.0,
5375                },
5376                minimized: false,
5377                fullscreen: false,
5378                standard: true,
5379                focused: false,
5380            };
5381            Self {
5382                trusted,
5383                windows: Arc::new(Mutex::new(vec![
5384                    window("plan.md — ref-tree", 0.0, 800.0),
5385                    window("main.rs — other-tree", 900.0, 500.0),
5386                ])),
5387                writes: Arc::new(Mutex::new(Vec::new())),
5388            }
5389        }
5390
5391        fn writes(&self) -> Vec<geometry::Frame> {
5392            self.writes
5393                .lock()
5394                .unwrap_or_else(PoisonError::into_inner)
5395                .clone()
5396        }
5397
5398        /// The frame a window currently occupies, after any applied writes.
5399        fn frame_of(&self, index: usize) -> geometry::Frame {
5400            self.windows.lock().unwrap_or_else(PoisonError::into_inner)[index].frame
5401        }
5402
5403        /// A factory for the `*_with` seams, sharing this stub's table and recorder.
5404        fn factory(&self) -> impl FnOnce() -> Self + Send + 'static {
5405            let clone = self.clone();
5406            move || clone
5407        }
5408    }
5409
5410    impl geometry::WindowBackend for StubBackend {
5411        fn trusted(&self) -> bool {
5412            self.trusted
5413        }
5414
5415        fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
5416            pids.iter()
5417                .filter(|p| **p == 11 || **p == 12)
5418                .map(|p| (*p, 900))
5419                .collect()
5420        }
5421
5422        fn windows(&self, app_pid: u32) -> Result<Vec<geometry::OsWindow>, String> {
5423            if app_pid != 900 {
5424                return Ok(Vec::new());
5425            }
5426            Ok(self
5427                .windows
5428                .lock()
5429                .unwrap_or_else(PoisonError::into_inner)
5430                .clone())
5431        }
5432
5433        fn set_frame(
5434            &self,
5435            id: geometry::WindowId,
5436            frame: geometry::Frame,
5437        ) -> Result<geometry::Frame, String> {
5438            self.writes
5439                .lock()
5440                .unwrap_or_else(PoisonError::into_inner)
5441                .push(frame);
5442            let mut windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);
5443            let window = windows
5444                .get_mut(id.index)
5445                .ok_or_else(|| format!("no window at index {}", id.index))?;
5446            window.frame = frame;
5447            Ok(frame)
5448        }
5449    }
5450
5451    /// Registers a window whose reported title is `title` and pid is `pid`, i.e.
5452    /// one the stub backend can resolve to an OS window.
5453    fn register_window(svc: &WorktreesService, key: &str, title: &str, pid: u32) {
5454        svc.registry.register(
5455            serde_json::from_value(json!({
5456                "key": key,
5457                "folders": [format!("/tmp/{key}")],
5458                "title": title,
5459                "pid": pid,
5460            }))
5461            .expect("valid register payload"),
5462        );
5463    }
5464
5465    #[tokio::test]
5466    async fn reposition_requires_a_resolvable_reference() {
5467        let svc = WorktreesService::new();
5468        // A missing, blank, or unknown reference key is a hard error: with no
5469        // reference there is no geometry to copy, so the request is meaningless.
5470        assert!(svc.handle("reposition", json!({})).await.is_err());
5471        assert!(svc
5472            .handle("reposition", json!({ "reference_key": "  " }))
5473            .await
5474            .is_err());
5475        assert!(svc
5476            .handle("reposition", json!({ "reference_key": "ghost" }))
5477            .await
5478            .is_err());
5479    }
5480
5481    #[tokio::test]
5482    async fn reposition_moves_targets_and_records_an_undo() {
5483        let svc = WorktreesService::new();
5484        register_window(&svc, "ref", "ref-tree", 11);
5485        register_window(&svc, "other", "other-tree", 12);
5486        let backend = StubBackend::new(true);
5487
5488        let reply = svc
5489            .reposition_with(
5490                serde_json::from_value(json!({
5491                    "reference_key": "ref",
5492                    "target_keys": ["other"],
5493                }))
5494                .unwrap(),
5495                backend.factory(),
5496            )
5497            .await
5498            .unwrap();
5499
5500        assert_eq!(reply["trusted"], json!(true));
5501        assert_eq!(reply["moved"], json!(1));
5502        assert_eq!(reply["skipped"], json!(0));
5503        assert_eq!(reply["undoable"], json!(true));
5504        assert_eq!(reply["reference"]["title"], json!("ref-tree"));
5505        assert_eq!(reply["results"][0]["key"], json!("other"));
5506        assert_eq!(reply["results"][0]["outcome"], json!("moved"));
5507        // Written the reference's own frame, read from the stub's window table.
5508        assert_eq!(backend.writes().len(), 1);
5509        assert_eq!(
5510            backend.writes()[0],
5511            backend.frame_of(0),
5512            "wrote the reference window's own frame"
5513        );
5514        assert_eq!(
5515            backend.frame_of(1),
5516            backend.frame_of(0),
5517            "the target now occupies the reference's frame"
5518        );
5519
5520        // Undo puts it back where it was — the same backend, so it sees the window
5521        // where the move left it — and consumes the record, so a second undo has
5522        // nothing left to replay onto a layout the user may have since redone.
5523        let undone = svc.reposition_undo_with(backend.factory()).await.unwrap();
5524        assert_eq!(undone["moved"], json!(1));
5525        assert_eq!(undone["results"][0]["outcome"], json!("moved"));
5526        assert!(undone.get("reference").is_none(), "undo has no reference");
5527        assert_eq!(
5528            backend.frame_of(1),
5529            geometry::Frame {
5530                x: 900.0,
5531                y: 0.0,
5532                width: 500.0,
5533                height: 600.0,
5534            },
5535            "restored to exactly the pre-move frame"
5536        );
5537
5538        let again = svc.reposition_undo_with(backend.factory()).await.unwrap();
5539        assert_eq!(
5540            again,
5541            json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 })
5542        );
5543    }
5544
5545    #[tokio::test]
5546    async fn a_reposition_dry_run_writes_nothing_and_leaves_no_undo() {
5547        let svc = WorktreesService::new();
5548        register_window(&svc, "ref", "ref-tree", 11);
5549        register_window(&svc, "other", "other-tree", 12);
5550        let backend = StubBackend::new(true);
5551
5552        let reply = svc
5553            .reposition_with(
5554                serde_json::from_value(json!({
5555                    "reference_key": "ref",
5556                    "target_keys": ["other"],
5557                    "check": true,
5558                }))
5559                .unwrap(),
5560                backend.factory(),
5561            )
5562            .await
5563            .unwrap();
5564
5565        assert_eq!(reply["results"][0]["outcome"], json!("would-move"));
5566        assert!(
5567            reply.get("undoable").is_none(),
5568            "a dry run leaves nothing to undo"
5569        );
5570        assert!(
5571            backend.writes().is_empty(),
5572            "a dry run must not touch a window"
5573        );
5574    }
5575
5576    #[tokio::test]
5577    async fn reposition_reports_a_missing_permission_as_data() {
5578        let svc = WorktreesService::new();
5579        register_window(&svc, "ref", "ref-tree", 11);
5580        register_window(&svc, "other", "other-tree", 12);
5581        let backend = StubBackend::new(false);
5582
5583        let reply = svc
5584            .reposition_with(
5585                serde_json::from_value(json!({
5586                    "reference_key": "ref",
5587                    "target_keys": ["other"],
5588                }))
5589                .unwrap(),
5590                backend.factory(),
5591            )
5592            .await
5593            .unwrap();
5594
5595        // Not an error: the client branches on `trusted` to offer the user a link
5596        // to the Accessibility settings pane.
5597        assert_eq!(reply["trusted"], json!(false));
5598        assert_eq!(reply["moved"], json!(0));
5599        assert!(backend.writes().is_empty());
5600    }
5601
5602    #[tokio::test]
5603    async fn a_stale_target_key_is_skipped_not_fatal() {
5604        let svc = WorktreesService::new();
5605        register_window(&svc, "ref", "ref-tree", 11);
5606        register_window(&svc, "other", "other-tree", 12);
5607        let backend = StubBackend::new(true);
5608
5609        let reply = svc
5610            .reposition_with(
5611                serde_json::from_value(json!({
5612                    "reference_key": "ref",
5613                    // A window that closed since the tree row was rendered, the
5614                    // reference itself, and a real target.
5615                    "target_keys": ["closed-since", "ref", "other"],
5616                }))
5617                .unwrap(),
5618                backend.factory(),
5619            )
5620            .await
5621            .unwrap();
5622
5623        let outcomes: Vec<&str> = reply["results"]
5624            .as_array()
5625            .unwrap()
5626            .iter()
5627            .map(|r| r["outcome"].as_str().unwrap())
5628            .collect();
5629        assert_eq!(outcomes, vec!["no-window", "reference", "moved"]);
5630        assert_eq!(reply["moved"], json!(1));
5631        assert_eq!(reply["skipped"], json!(2));
5632    }
5633
5634    #[tokio::test]
5635    async fn a_blocked_reposition_carries_the_reason_and_records_no_undo() {
5636        let svc = WorktreesService::new();
5637        // Two windows share a root name, so the *reference* cannot be resolved and
5638        // the whole batch is refused before any target is attempted.
5639        register_window(&svc, "ref", "twin", 11);
5640        register_window(&svc, "other", "other-tree", 12);
5641        // The window table is behind an `Arc<Mutex<…>>`, so retitling it needs no
5642        // `mut` binding — and the same shared table backs the factory's clone.
5643        let backend = StubBackend::new(true);
5644        {
5645            let mut windows = backend
5646                .windows
5647                .lock()
5648                .unwrap_or_else(PoisonError::into_inner);
5649            windows[0].title = "a.rs — twin".to_string();
5650            windows[1].title = "b.rs — twin".to_string();
5651        }
5652
5653        let reply = svc
5654            .reposition_with(
5655                serde_json::from_value(json!({
5656                    "reference_key": "ref",
5657                    "target_keys": ["other"],
5658                }))
5659                .unwrap(),
5660                backend.factory(),
5661            )
5662            .await
5663            .unwrap();
5664
5665        assert_eq!(reply["trusted"], json!(true));
5666        assert_eq!(reply["blocked"]["reason"], json!("reference-ambiguous"));
5667        assert!(
5668            reply["blocked"]["detail"]
5669                .as_str()
5670                .is_some_and(|d| d.contains("twin")),
5671            "the reason should name the ambiguous title: {reply}"
5672        );
5673        assert_eq!(reply["results"], json!([]), "no target is attempted");
5674        assert!(reply.get("undoable").is_none());
5675        assert!(backend.writes().is_empty());
5676
5677        // And nothing was recorded, so a following undo has nothing to replay.
5678        let undone = svc
5679            .reposition_undo_with(StubBackend::new(true).factory())
5680            .await
5681            .unwrap();
5682        assert_eq!(undone["moved"], json!(0));
5683    }
5684
5685    #[tokio::test]
5686    async fn reposition_undo_is_a_no_op_with_nothing_recorded() {
5687        let svc = WorktreesService::new();
5688        let reply = svc.handle("reposition-undo", Value::Null).await.unwrap();
5689        assert_eq!(reply["moved"], json!(0));
5690        assert_eq!(reply["results"], json!([]));
5691    }
5692
5693    #[test]
5694    fn outcome_kinds_joins_slugs_and_dashes_an_empty_batch() {
5695        let empty = geometry::RepositionReport {
5696            trusted: true,
5697            blocked: None,
5698            reference: None,
5699            results: Vec::new(),
5700            undo: Vec::new(),
5701        };
5702        assert_eq!(outcome_kinds(&empty), "-");
5703    }
5704
5705    #[tokio::test]
5706    async fn handle_rejects_missing_or_empty_key() {
5707        let svc = WorktreesService::new();
5708        // register validates a present, non-blank key.
5709        assert!(svc.handle("register", json!({})).await.is_err());
5710        assert!(svc
5711            .handle("register", json!({ "key": "  " }))
5712            .await
5713            .is_err());
5714        // heartbeat/unregister require the key via `require_str`.
5715        assert!(svc.handle("heartbeat", json!({})).await.is_err());
5716        assert!(svc.handle("unregister", json!({})).await.is_err());
5717    }
5718
5719    #[test]
5720    fn display_name_prefers_repo_then_folder_basename() {
5721        let base = WindowEntry {
5722            key: "k".to_string(),
5723            folders: vec![PathBuf::from("/home/me/project")],
5724            repo: Some("my-repo".to_string()),
5725            title: None,
5726            pid: None,
5727            last_seen: Utc::now(),
5728            last_active: Duration::ZERO,
5729        };
5730        assert_eq!(display_name(&base), "my-repo");
5731
5732        let no_repo = WindowEntry {
5733            repo: None,
5734            ..base.clone()
5735        };
5736        assert_eq!(display_name(&no_repo), "project");
5737
5738        let nothing = WindowEntry {
5739            repo: None,
5740            folders: vec![],
5741            ..base.clone()
5742        };
5743        assert_eq!(display_name(&nothing), "(no folder)");
5744
5745        // A folder with no basename (the filesystem root) falls back to its
5746        // displayed path rather than panicking or yielding an empty name.
5747        let rootish = WindowEntry {
5748            repo: None,
5749            folders: vec![PathBuf::from("/")],
5750            ..base
5751        };
5752        assert_eq!(display_name(&rootish), "/");
5753    }
5754
5755    #[test]
5756    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
5757        let now = Utc::now();
5758        let entries = vec![
5759            // A folderless window has nothing to focus, so it stays a plain
5760            // Label; a title equal to the name collapses to just the name. It
5761            // leads the list so the focus-action lookup below is exercised
5762            // against a leading non-Action item it has to skip.
5763            WindowEntry {
5764                key: "k2".to_string(),
5765                folders: vec![],
5766                repo: Some("solo".to_string()),
5767                title: Some("solo".to_string()),
5768                pid: None,
5769                last_seen: now,
5770                last_active: Duration::ZERO,
5771            },
5772            // A folder-bearing, non-repo window: one clickable Action whose label
5773            // is the stats line ("name · title", since /tmp is not a git repo).
5774            WindowEntry {
5775                key: "k1".to_string(),
5776                folders: vec![PathBuf::from("/tmp/a")],
5777                repo: Some("repo".to_string()),
5778                title: Some("a branch".to_string()),
5779                pid: None,
5780                last_seen: now,
5781                last_active: Duration::ZERO,
5782            },
5783        ];
5784        let items = window_menu_items(&entries);
5785        // Exactly one item per window — no duplicate label, no separator.
5786        assert_eq!(items.len(), 2);
5787        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
5788
5789        // The folder-bearing window is a single clickable action carrying the
5790        // stats label (the old label + Focus action, merged).
5791        let action = items
5792            .iter()
5793            .find_map(|i| match i {
5794                MenuItem::Action(a) => Some(a),
5795                _ => None,
5796            })
5797            .expect("a focus action");
5798        assert_eq!(action.id, "focus:k1");
5799        assert_eq!(action.label, "repo · a branch");
5800
5801        // The folderless window is a non-clickable label (not "solo · solo").
5802        let labels: Vec<&str> = items
5803            .iter()
5804            .filter_map(|i| match i {
5805                MenuItem::Label(t) => Some(t.as_str()),
5806                _ => None,
5807            })
5808            .collect();
5809        assert_eq!(labels, vec!["solo"]);
5810    }
5811
5812    #[tokio::test]
5813    async fn menu_and_status_shapes() {
5814        let svc = WorktreesService::new();
5815        // Empty.
5816        let menu = svc.menu();
5817        assert_eq!(menu.title, "Worktrees");
5818        assert!(matches!(
5819            menu.items.first(),
5820            Some(MenuItem::Label(text)) if text == "No open windows"
5821        ));
5822        let status = svc.status().await;
5823        assert_eq!(status.name, "worktrees");
5824        assert!(status.healthy);
5825        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
5826
5827        // Two folder-bearing windows in the same repo, plus one folderless
5828        // window that shares the repo but has nothing for `code` to open.
5829        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5830            .await
5831            .unwrap();
5832        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
5833            .await
5834            .unwrap();
5835        svc.handle(
5836            "register",
5837            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
5838        )
5839        .await
5840        .unwrap();
5841        let status = svc.status().await;
5842        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
5843
5844        let menu = svc.menu();
5845        // One line per window — no separator, no duplicate label.
5846        assert_eq!(menu.items.len(), 3);
5847        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
5848        let action_ids: Vec<&str> = menu
5849            .items
5850            .iter()
5851            .filter_map(|i| match i {
5852                MenuItem::Action(a) => Some(a.id.as_str()),
5853                _ => None,
5854            })
5855            .collect();
5856        // The two folder-bearing windows are clickable; the folderless one is a
5857        // plain Label, so it never yields a focus action.
5858        assert!(action_ids.contains(&"focus:w1"));
5859        assert!(action_ids.contains(&"focus:w2"));
5860        assert!(!action_ids.contains(&"focus:w3"));
5861    }
5862
5863    #[test]
5864    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
5865        // With no tokio runtime, the background task is never spawned, so the
5866        // bare service keeps computing `menu()` inline (what the tests rely on).
5867        let svc = WorktreesService::new();
5868        svc.start_menu_refresh();
5869        assert!(svc.refresh.lock().unwrap().is_none());
5870    }
5871
5872    #[tokio::test]
5873    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
5874        let svc = WorktreesService::new();
5875        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
5876            .await
5877            .unwrap();
5878        // Before the task runs, `menu()` computes inline from an empty cache.
5879        assert!(svc.menu_cache.lock().unwrap().is_none());
5880
5881        svc.start_menu_refresh();
5882        // Idempotent: a second call does not start a second task.
5883        svc.start_menu_refresh();
5884
5885        // The task fills the cache off the main thread; poll briefly for it.
5886        let mut filled = false;
5887        for _ in 0..100 {
5888            if svc.menu_cache.lock().unwrap().is_some() {
5889                filled = true;
5890                break;
5891            }
5892            tokio::time::sleep(Duration::from_millis(10)).await;
5893        }
5894        assert!(filled, "background refresh should populate the menu cache");
5895
5896        // `menu()` now serves the cache: one clickable line for the window.
5897        let menu = svc.menu();
5898        assert_eq!(menu.title, "Worktrees");
5899        assert!(menu
5900            .items
5901            .iter()
5902            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
5903
5904        // Shutdown cancels and joins the task, clearing the handle.
5905        svc.shutdown().await;
5906        assert!(svc.refresh.lock().unwrap().is_none());
5907    }
5908
5909    #[tokio::test]
5910    async fn default_constructs_an_empty_service() {
5911        let svc = WorktreesService::default();
5912        let payload = svc.handle("list", Value::Null).await.unwrap();
5913        assert_eq!(payload, json!({ "windows": [] }));
5914    }
5915
5916    // --- Push subscription (#1267) -----------------------------------------
5917
5918    #[tokio::test]
5919    async fn subscribe_streams_only_for_the_subscribe_op() {
5920        let svc = WorktreesService::new();
5921        // The one streaming op yields a stream; every other op (including the
5922        // request/reply worktrees ops) declines, so the server dispatches them
5923        // normally.
5924        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
5925        assert!(svc.subscribe("list", &Value::Null).is_none());
5926        assert!(svc.subscribe("register", &Value::Null).is_none());
5927        assert!(svc.subscribe("bogus", &Value::Null).is_none());
5928    }
5929
5930    #[tokio::test]
5931    async fn subscribe_snapshot_matches_the_tree_op() {
5932        let dir = tempfile::tempdir().unwrap();
5933        let repo = init_repo(dir.path());
5934        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
5935        repo.set_head("refs/heads/main").unwrap();
5936
5937        let svc = WorktreesService::new();
5938        let stream = svc
5939            .subscribe("subscribe", &Value::Null)
5940            .expect("subscribe stream");
5941        // No windows yet → no repos derived; the toggle rides along at its
5942        // default (show all).
5943        assert_eq!(
5944            stream.snapshot().await,
5945            json!({ "repos": [], "show_closed": true })
5946        );
5947
5948        // A window opens on the repo → the snapshot carries it, byte-identical to
5949        // what the `tree` op returns for the same registry state.
5950        svc.handle(
5951            "register",
5952            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
5953        )
5954        .await
5955        .unwrap();
5956        let snap = stream.snapshot().await;
5957        let tree = svc.handle("tree", Value::Null).await.unwrap();
5958        assert_eq!(snap, tree);
5959        let repos = snap["repos"].as_array().expect("repos array");
5960        assert_eq!(repos.len(), 1);
5961        assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
5962    }
5963
5964    #[tokio::test]
5965    async fn subscribe_changed_wakes_on_register() {
5966        let svc = WorktreesService::new();
5967        let mut stream = svc
5968            .subscribe("subscribe", &Value::Null)
5969            .expect("subscribe stream");
5970        // Idle: `changed()` must not resolve without a registry change.
5971        tokio::select! {
5972            () = stream.changed() => panic!("changed resolved with no registry change"),
5973            () = tokio::time::sleep(Duration::from_millis(50)) => {}
5974        }
5975        // A register bumps the change-notify → `changed()` resolves promptly.
5976        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
5977            .await
5978            .unwrap();
5979        tokio::time::timeout(Duration::from_secs(1), stream.changed())
5980            .await
5981            .expect("changed should resolve after a register");
5982    }
5983
5984    // --- Coalesced tree-snapshot cache (#1303) -----------------------------
5985
5986    #[tokio::test]
5987    async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
5988        let reg = Arc::new(WorktreesRegistry::new());
5989        // A long TTL so only the generation gate is exercised here.
5990        let cache = TreeSnapshotCache::with_ttl(
5991            reg,
5992            Arc::new(PrStatusCache::new()),
5993            Duration::from_secs(60),
5994        );
5995        // The first read builds once.
5996        let first = cache.snapshot().await;
5997        assert_eq!(cache.compute_count(), 1);
5998        // Further reads with no registry change and within the TTL reuse the
5999        // cached value — no extra build, byte-identical result.
6000        let second = cache.snapshot().await;
6001        assert_eq!(
6002            cache.compute_count(),
6003            1,
6004            "an unchanged read must not rebuild"
6005        );
6006        assert_eq!(first, second);
6007    }
6008
6009    #[tokio::test]
6010    async fn tree_cache_single_flights_a_read_burst() {
6011        let reg = Arc::new(WorktreesRegistry::new());
6012        let cache = Arc::new(TreeSnapshotCache::with_ttl(
6013            reg,
6014            Arc::new(PrStatusCache::new()),
6015            Duration::from_secs(60),
6016        ));
6017        // A burst of concurrent readers — as N subscriber streams would wake
6018        // together on a change/tick — collapses to exactly one build; the rest
6019        // read the shared result (the acceptance criterion).
6020        let mut handles = Vec::new();
6021        for _ in 0..16 {
6022            let cache = cache.clone();
6023            handles.push(tokio::spawn(async move { cache.snapshot().await }));
6024        }
6025        let mut results = Vec::new();
6026        for handle in handles {
6027            results.push(handle.await.unwrap());
6028        }
6029        assert_eq!(
6030            cache.compute_count(),
6031            1,
6032            "a concurrent read burst must build the tree once"
6033        );
6034        assert!(
6035            results.windows(2).all(|w| w[0] == w[1]),
6036            "every reader must observe the identical snapshot"
6037        );
6038    }
6039
6040    #[tokio::test]
6041    async fn tree_cache_rebuilds_on_registry_change() {
6042        let reg = Arc::new(WorktreesRegistry::new());
6043        let cache = TreeSnapshotCache::with_ttl(
6044            reg.clone(),
6045            Arc::new(PrStatusCache::new()),
6046            Duration::from_secs(60),
6047        );
6048        cache.snapshot().await;
6049        assert_eq!(cache.compute_count(), 1);
6050        // A registry change bumps the generation, so the next read rebuilds even
6051        // though the (long) TTL has not expired — subscribers never see a stale
6052        // visible set.
6053        assert!(reg.set_show_closed(false));
6054        cache.snapshot().await;
6055        assert_eq!(
6056            cache.compute_count(),
6057            2,
6058            "a generation bump must force a rebuild"
6059        );
6060    }
6061
6062    #[tokio::test]
6063    async fn tree_cache_rebuilds_after_ttl_expiry() {
6064        let reg = Arc::new(WorktreesRegistry::new());
6065        // A zero TTL: every read is already past it, so a pure on-disk git change
6066        // still surfaces on the next tick with no registry bump needed.
6067        let cache =
6068            TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
6069        cache.snapshot().await;
6070        cache.snapshot().await;
6071        assert_eq!(
6072            cache.compute_count(),
6073            2,
6074            "an expired TTL must force a rebuild each read"
6075        );
6076    }
6077
6078    #[tokio::test]
6079    async fn subscribe_streams_share_one_build_per_generation() {
6080        let svc = WorktreesService::new();
6081        let s1 = svc
6082            .subscribe("subscribe", &Value::Null)
6083            .expect("subscribe stream");
6084        let s2 = svc
6085            .subscribe("subscribe", &Value::Null)
6086            .expect("subscribe stream");
6087        // Two windows' streams sampling the same registry state build the tree
6088        // once, not once per stream (#1303) — they share the service's cache.
6089        let a = s1.snapshot().await;
6090        let b = s2.snapshot().await;
6091        assert_eq!(a, b);
6092        assert_eq!(
6093            svc.tree_cache.compute_count(),
6094            1,
6095            "N streams on one generation must share a single build"
6096        );
6097    }
6098
6099    // --- Show/hide-closed toggle (#1301) -----------------------------------
6100
6101    #[tokio::test]
6102    async fn set_show_closed_toggles_the_snapshot_field() {
6103        let svc = WorktreesService::new();
6104        // The snapshot carries the toggle; it defaults to show-all.
6105        assert_eq!(
6106            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6107            json!(true)
6108        );
6109        // Setting it flips the field the next snapshot reports.
6110        let reply = svc
6111            .handle("set-show-closed", json!({ "show_closed": false }))
6112            .await
6113            .unwrap();
6114        assert_eq!(reply, json!({ "ok": true }));
6115        assert_eq!(
6116            svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
6117            json!(false)
6118        );
6119    }
6120
6121    #[tokio::test]
6122    async fn set_show_closed_rejects_a_non_boolean_payload() {
6123        let svc = WorktreesService::new();
6124        assert!(svc.handle("set-show-closed", json!({})).await.is_err());
6125        assert!(svc
6126            .handle("set-show-closed", json!({ "show_closed": "yes" }))
6127            .await
6128            .is_err());
6129    }
6130
6131    #[tokio::test]
6132    async fn set_show_closed_wakes_the_subscription() {
6133        let svc = WorktreesService::new();
6134        let mut stream = svc
6135            .subscribe("subscribe", &Value::Null)
6136            .expect("subscribe stream");
6137        // A real flip bumps the change-notify → `changed()` resolves promptly.
6138        svc.handle("set-show-closed", json!({ "show_closed": false }))
6139            .await
6140            .unwrap();
6141        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6142            .await
6143            .expect("changed should resolve after a toggle flip");
6144        // The pushed snapshot now reflects the new toggle.
6145        assert_eq!(stream.snapshot().await["show_closed"], json!(false));
6146    }
6147
6148    #[tokio::test]
6149    async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
6150        // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
6151        // drops it (skip-if-false), so the extension colours the icon off the flag.
6152        let dir = tempfile::tempdir().unwrap();
6153        github_repo(dir.path());
6154        let svc = WorktreesService::new();
6155        svc.handle(
6156            "register",
6157            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6158        )
6159        .await
6160        .unwrap();
6161
6162        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6163        assert!(
6164            repo.get("polling_enabled").is_none(),
6165            "default off omits the flag: {repo:?}"
6166        );
6167
6168        let reply = svc
6169            .handle(
6170                "set-polling",
6171                json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6172            )
6173            .await
6174            .unwrap();
6175        assert_eq!(reply, json!({ "ok": true }));
6176        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6177        assert_eq!(repo["polling_enabled"], json!(true));
6178
6179        svc.handle(
6180            "set-polling",
6181            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6182        )
6183        .await
6184        .unwrap();
6185        let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
6186        assert!(repo.get("polling_enabled").is_none());
6187    }
6188
6189    #[tokio::test]
6190    async fn set_polling_rejects_missing_or_empty_fields() {
6191        let svc = WorktreesService::new();
6192        // Missing `enabled`.
6193        assert!(svc
6194            .handle("set-polling", json!({ "owner": "o", "name": "n" }))
6195            .await
6196            .is_err());
6197        // Missing `owner`/`name`.
6198        assert!(svc
6199            .handle("set-polling", json!({ "enabled": true }))
6200            .await
6201            .is_err());
6202        // Blank `owner`/`name`.
6203        assert!(svc
6204            .handle(
6205                "set-polling",
6206                json!({ "owner": " ", "name": "n", "enabled": true })
6207            )
6208            .await
6209            .is_err());
6210    }
6211
6212    #[tokio::test]
6213    async fn set_polling_wakes_the_subscription() {
6214        let dir = tempfile::tempdir().unwrap();
6215        github_repo(dir.path());
6216        let svc = WorktreesService::new();
6217        svc.handle(
6218            "register",
6219            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6220        )
6221        .await
6222        .unwrap();
6223        let mut stream = svc
6224            .subscribe("subscribe", &Value::Null)
6225            .expect("subscribe stream");
6226        svc.handle(
6227            "set-polling",
6228            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6229        )
6230        .await
6231        .unwrap();
6232        tokio::time::timeout(Duration::from_secs(1), stream.changed())
6233            .await
6234            .expect("changed should resolve after enabling a repo");
6235        let repo = repos_of(&stream.snapshot().await)[0].clone();
6236        assert_eq!(repo["polling_enabled"], json!(true));
6237    }
6238
6239    #[tokio::test]
6240    async fn disabling_a_repo_drops_its_pr_badges_immediately() {
6241        // The "drop existing badges immediately" requirement (#1376), done
6242        // daemon-side: the fold skips a not-polled repo, so a disable strips the
6243        // badge on the very next snapshot rather than waiting for a poll.
6244        let dir = tempfile::tempdir().unwrap();
6245        let repo = github_repo(dir.path());
6246        let head = repo.head().unwrap().target().unwrap().to_string();
6247        let svc = WorktreesService::new();
6248        svc.handle(
6249            "register",
6250            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6251        )
6252        .await
6253        .unwrap();
6254        svc.registry.set_polling("rust-works", "omni-dev", true);
6255
6256        let mut badges = HashMap::new();
6257        badges.insert(
6258            PrTarget {
6259                owner: "rust-works".into(),
6260                name: "omni-dev".into(),
6261                branch: "main".into(),
6262            },
6263            pr(pending_badge(7, &head)),
6264        );
6265        svc.pr_cache.replace(badges);
6266
6267        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6268        assert_eq!(wt["pr"]["number"], json!(7));
6269
6270        svc.handle(
6271            "set-polling",
6272            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6273        )
6274        .await
6275        .unwrap();
6276        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6277        assert!(
6278            wt.get("pr").is_none(),
6279            "a disabled repo must carry no badge: {wt:?}"
6280        );
6281    }
6282
6283    #[tokio::test]
6284    async fn an_expired_lease_drops_the_flag_and_badges() {
6285        // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
6286        // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
6287        // poller would no longer watch it — all reaped on read, no timer.
6288        let dir = tempfile::tempdir().unwrap();
6289        let repo = github_repo(dir.path());
6290        let head = repo.head().unwrap().target().unwrap().to_string();
6291        let svc = WorktreesService::new();
6292        svc.handle(
6293            "register",
6294            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6295        )
6296        .await
6297        .unwrap();
6298        svc.registry.set_polling("rust-works", "omni-dev", true);
6299        let mut badges = HashMap::new();
6300        badges.insert(
6301            PrTarget {
6302                owner: "rust-works".into(),
6303                name: "omni-dev".into(),
6304                branch: "main".into(),
6305            },
6306            pr(pending_badge(7, &head)),
6307        );
6308        svc.pr_cache.replace(badges);
6309
6310        // Leased: flag stamped, badge folded, and the poller would watch it.
6311        let snap = svc.handle("tree", Value::Null).await.unwrap();
6312        assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
6313        assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
6314        assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
6315
6316        // Force the lease into the past — as 15 minutes elapsing would.
6317        svc.registry.set_polling_expiry(
6318            "rust-works",
6319            "omni-dev",
6320            Utc::now() - chrono::Duration::minutes(1),
6321        );
6322
6323        let snap = svc.handle("tree", Value::Null).await.unwrap();
6324        let repo0 = &repos_of(&snap)[0];
6325        assert!(
6326            repo0.get("polling_enabled").is_none(),
6327            "expired lease drops the flag: {repo0:?}"
6328        );
6329        assert!(
6330            repo0["worktrees"][0].get("pr").is_none(),
6331            "expired lease drops the badge"
6332        );
6333        assert!(
6334            pr_targets_from_snapshot(&snap).is_empty(),
6335            "the poller no longer watches an expired repo"
6336        );
6337    }
6338
6339    #[tokio::test]
6340    async fn polling_prefs_persist_across_reloads_with_0600() {
6341        // The enable set survives a daemon restart (#1376): a change writes the
6342        // `0600` file, and a fresh service seeded from it comes up enabled.
6343        let dir = tempfile::tempdir().unwrap();
6344        let prefs = dir.path().join("worktrees-polling.json");
6345
6346        let svc = WorktreesService::new();
6347        svc.load_polling_prefs(prefs.clone());
6348        assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
6349        svc.handle(
6350            "set-polling",
6351            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
6352        )
6353        .await
6354        .unwrap();
6355        assert!(prefs.exists());
6356        #[cfg(unix)]
6357        {
6358            use std::os::unix::fs::PermissionsExt;
6359            assert_eq!(
6360                std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
6361                0o600
6362            );
6363        }
6364
6365        // A new service reloads the enabled set from that file.
6366        let svc2 = WorktreesService::new();
6367        svc2.load_polling_prefs(prefs.clone());
6368        assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
6369
6370        // Disabling rewrites the file, so the next reload has nothing enabled.
6371        svc2.handle(
6372            "set-polling",
6373            json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
6374        )
6375        .await
6376        .unwrap();
6377        let svc3 = WorktreesService::new();
6378        svc3.load_polling_prefs(prefs);
6379        assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
6380    }
6381
6382    #[test]
6383    fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
6384        // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
6385        // path is logged and treated as "nothing enabled" rather than wedging the
6386        // service. A missing file is already the first-run default (covered by the
6387        // persistence round-trip above); this exercises the two error branches.
6388        let dir = tempfile::tempdir().unwrap();
6389
6390        // Corrupt JSON — the parse error is swallowed, nothing is enabled.
6391        let corrupt = dir.path().join("worktrees-polling.json");
6392        std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
6393        let svc = WorktreesService::new();
6394        svc.load_polling_prefs(corrupt);
6395        assert!(svc.registry.enabled_polling_repos().is_empty());
6396
6397        // A directory at the path — the (non-NotFound) read error is swallowed too.
6398        let as_dir = dir.path().join("is-a-directory");
6399        std::fs::create_dir(&as_dir).unwrap();
6400        let svc2 = WorktreesService::new();
6401        svc2.load_polling_prefs(as_dir);
6402        assert!(svc2.registry.enabled_polling_repos().is_empty());
6403    }
6404
6405    #[tokio::test]
6406    async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
6407        // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
6408        // the user has not enabled polling for it — so the poller spawns no `gh`.
6409        let dir = tempfile::tempdir().unwrap();
6410        github_repo(dir.path());
6411        let bin_dir = tempfile::tempdir().unwrap();
6412        let marker = bin_dir.path().join("spawned");
6413        let fake = bin_dir.path().join("fake-gh");
6414        std::fs::write(
6415            &fake,
6416            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
6417        )
6418        .unwrap();
6419        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
6420        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
6421        std::fs::set_permissions(&fake, perms).unwrap();
6422
6423        let svc = WorktreesService::new();
6424        svc.handle(
6425            "register",
6426            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6427        )
6428        .await
6429        .unwrap();
6430        // Deliberately NOT enabling polling for the repo.
6431        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
6432        tokio::time::sleep(Duration::from_millis(200)).await;
6433        svc.shutdown().await;
6434        assert!(
6435            !marker.exists(),
6436            "a registered-but-not-enabled repo must drive zero gh"
6437        );
6438    }
6439
6440    #[tokio::test]
6441    async fn menu_action_rejects_unknown_and_missing_window() {
6442        let svc = WorktreesService::new();
6443        assert!(svc.menu_action("bogus").await.is_err());
6444        // A focus for a key with no registration errors rather than spawning.
6445        assert!(svc.menu_action("focus:nope").await.is_err());
6446        svc.shutdown().await;
6447    }
6448
6449    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
6450    /// variable (via `resolve_code_binary` → `focus_window`) —
6451    /// `menu_action_focus_resolves_folder_and_spawns` and
6452    /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
6453    /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
6454    /// transient overlap under the harness's test parallelism is benign.
6455    struct VscodeBinGuard(Option<std::ffi::OsString>);
6456    impl Drop for VscodeBinGuard {
6457        fn drop(&mut self) {
6458            match self.0.take() {
6459                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
6460                None => std::env::remove_var(VSCODE_BIN_ENV),
6461            }
6462        }
6463    }
6464
6465    #[tokio::test]
6466    async fn menu_action_focus_resolves_folder_and_spawns() {
6467        let dir = tempfile::tempdir().unwrap();
6468        let svc = WorktreesService::new();
6469        svc.handle(
6470            "register",
6471            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6472        )
6473        .await
6474        .unwrap();
6475
6476        // Point the launcher at a harmless binary so the spawn deterministically
6477        // succeeds and the focus path returns Ok.
6478        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6479        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6480        svc.menu_action("focus:w1").await.unwrap();
6481    }
6482
6483    #[tokio::test]
6484    async fn open_rejects_missing_relative_or_nonexistent_path() {
6485        let svc = WorktreesService::new();
6486        // A missing `path` is a payload error.
6487        assert!(svc.handle("open", json!({})).await.is_err());
6488        assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
6489        // A relative path is rejected before any spawn — this is also what
6490        // blocks a `-`-leading argument from reaching `code` as a flag.
6491        assert!(svc
6492            .handle("open", json!({ "path": "relative/dir" }))
6493            .await
6494            .is_err());
6495        assert!(svc
6496            .handle("open", json!({ "path": "-flag" }))
6497            .await
6498            .is_err());
6499        // An absolute path that does not exist is rejected before any spawn, so
6500        // no launcher is needed for these guard cases.
6501        assert!(svc
6502            .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
6503            .await
6504            .is_err());
6505        svc.shutdown().await;
6506    }
6507
6508    #[tokio::test]
6509    async fn open_focuses_an_existing_absolute_dir() {
6510        let dir = tempfile::tempdir().unwrap();
6511        let svc = WorktreesService::new();
6512        // Pin the launcher to a harmless binary so the spawn deterministically
6513        // succeeds whether or not `code` is installed. Unlike the tray `focus`
6514        // path, `open` takes the folder straight from the payload — no prior
6515        // `register` is required.
6516        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
6517        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
6518        let reply = svc
6519            .handle("open", json!({ "path": dir.path() }))
6520            .await
6521            .unwrap();
6522        assert_eq!(reply, json!({ "ok": true }));
6523        svc.shutdown().await;
6524    }
6525
6526    #[test]
6527    fn focus_window_with_validates_folder_then_spawns() {
6528        let dir = tempfile::tempdir().unwrap();
6529        // Non-absolute and missing-directory folders are rejected before spawn.
6530        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
6531        assert!(
6532            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
6533        );
6534        // A valid absolute directory spawns the launcher successfully.
6535        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
6536        // A missing launcher surfaces the spawn error (with context), not Ok.
6537        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
6538    }
6539
6540    #[test]
6541    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
6542        // Env override wins outright.
6543        assert_eq!(
6544            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
6545            PathBuf::from("/custom/code")
6546        );
6547        // No override: the first existing candidate is chosen.
6548        let existing = tempfile::NamedTempFile::new().unwrap();
6549        let existing_path = existing.path().to_str().unwrap();
6550        assert_eq!(
6551            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
6552            PathBuf::from(existing_path)
6553        );
6554        // Nothing exists: fall back to bare `code` on PATH.
6555        assert_eq!(
6556            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
6557            PathBuf::from("code")
6558        );
6559        // The real-env wrapper resolves without panicking.
6560        let _ = resolve_code_binary();
6561    }
6562
6563    // --- Git enrichment (#1186) --------------------------------------------
6564
6565    /// Initializes a fresh repo with a deterministic identity so `commit()`
6566    /// works without depending on a global git config.
6567    fn init_repo(dir: &Path) -> Repository {
6568        let repo = Repository::init(dir).unwrap();
6569        let mut cfg = repo.config().unwrap();
6570        cfg.set_str("user.name", "Test").unwrap();
6571        cfg.set_str("user.email", "test@example.com").unwrap();
6572        repo
6573    }
6574
6575    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
6576    /// optionally moving `refname` to it, and returns its oid.
6577    fn empty_commit(
6578        repo: &Repository,
6579        refname: Option<&str>,
6580        parents: &[&git2::Commit<'_>],
6581        msg: &str,
6582    ) -> git2::Oid {
6583        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6584        let tree = repo
6585            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
6586            .unwrap();
6587        repo.commit(refname, &sig, &sig, msg, &tree, parents)
6588            .unwrap()
6589    }
6590
6591    /// Commits `content` as file `name` onto `refname`, chaining off its current
6592    /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
6593    /// the file is checked out into a worktree and can then be modified to
6594    /// produce a dirty (tracked) status.
6595    fn commit_file(
6596        repo: &Repository,
6597        refname: &str,
6598        name: &str,
6599        content: &[u8],
6600        msg: &str,
6601    ) -> git2::Oid {
6602        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
6603        let blob = repo.blob(content).unwrap();
6604        let mut builder = repo.treebuilder(None).unwrap();
6605        builder.insert(name, blob, 0o100_644).unwrap();
6606        let tree = repo.find_tree(builder.write().unwrap()).unwrap();
6607        let parent = repo
6608            .refname_to_id(refname)
6609            .ok()
6610            .and_then(|oid| repo.find_commit(oid).ok());
6611        let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
6612        repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
6613            .unwrap()
6614    }
6615
6616    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
6617    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
6618    fn diverging_repo(dir: &Path) -> Repository {
6619        let repo = init_repo(dir);
6620        // A: the shared base on `main`.
6621        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6622        let a_commit = repo.find_commit(a).unwrap();
6623        // origin/main diverges to C, a sibling of the local tip.
6624        let c = empty_commit(&repo, None, &[&a_commit], "C");
6625        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6626            .unwrap();
6627        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
6628        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
6629        // Release the commit's borrow of `repo` so it can be returned.
6630        drop(a_commit);
6631        repo.set_head("refs/heads/main").unwrap();
6632        // Configure the tracking relationship so `upstream()` resolves.
6633        let mut cfg = repo.config().unwrap();
6634        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6635            .unwrap();
6636        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6637            .unwrap();
6638        cfg.set_str("branch.main.remote", "origin").unwrap();
6639        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
6640        repo
6641    }
6642
6643    /// Builds a repo whose `main` has **no upstream configured** but is 1 commit
6644    /// behind a resolvable `origin/main` — the "no own upstream, but behind the
6645    /// default branch" case [`repo_main_behind`] exists for (#1457). No
6646    /// `origin/HEAD` symref is set, so resolution goes through
6647    /// [`RemoteInfo::detect_main_branch_local`]'s common-names fallback.
6648    fn behind_main_no_upstream_repo(dir: &Path) -> Repository {
6649        let repo = init_repo(dir);
6650        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6651        let a_commit = repo.find_commit(a).unwrap();
6652        let c = empty_commit(&repo, None, &[&a_commit], "C");
6653        repo.reference("refs/remotes/origin/main", c, true, "origin main")
6654            .unwrap();
6655        drop(a_commit);
6656        repo.set_head("refs/heads/main").unwrap();
6657        repo
6658    }
6659
6660    #[test]
6661    fn git_status_reads_branch_and_ahead_behind() {
6662        let dir = tempfile::tempdir().unwrap();
6663        let _repo = diverging_repo(dir.path());
6664        let status = git_status(dir.path());
6665        assert_eq!(status.branch.as_deref(), Some("main"));
6666        assert_eq!(status.ahead, Some(1));
6667        assert_eq!(status.behind, Some(1));
6668        // A normal checkout names itself and is not flagged a worktree.
6669        assert_eq!(
6670            status.main_repo.as_deref(),
6671            dir.path().file_name().and_then(|n| n.to_str())
6672        );
6673        assert!(!status.is_worktree);
6674    }
6675
6676    #[test]
6677    fn git_status_empty_repo_is_unborn() {
6678        // A repo with no commits has an unborn HEAD, so `head()` errors and the
6679        // branch/sync fields stay empty rather than panicking — but the repo
6680        // identity is still resolved from the common dir.
6681        let dir = tempfile::tempdir().unwrap();
6682        init_repo(dir.path());
6683        let status = git_status(dir.path());
6684        assert_eq!(status.branch, None);
6685        // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
6686        assert_eq!(status.head_sha, None);
6687        assert_eq!(status.ahead, None);
6688        assert_eq!(status.behind, None);
6689        assert_eq!(
6690            status.main_repo.as_deref(),
6691            dir.path().file_name().and_then(|n| n.to_str())
6692        );
6693        assert!(!status.is_worktree);
6694    }
6695
6696    #[test]
6697    fn git_status_no_upstream_reports_branch_only() {
6698        let dir = tempfile::tempdir().unwrap();
6699        let repo = init_repo(dir.path());
6700        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6701        repo.set_head("refs/heads/main").unwrap();
6702        let status = git_status(dir.path());
6703        assert_eq!(status.branch.as_deref(), Some("main"));
6704        // No upstream → ahead/behind stay absent rather than zero.
6705        assert_eq!(status.ahead, None);
6706        assert_eq!(status.behind, None);
6707        // …and so does the upstream SHA, so such a branch still renders with no
6708        // sync indicator at all (#1344).
6709        assert_eq!(status.upstream_sha, None);
6710    }
6711
6712    #[test]
6713    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
6714        // A plain directory that is not a git repo yields nothing at all.
6715        let plain = tempfile::tempdir().unwrap();
6716        assert_eq!(git_status(plain.path()), GitStatus::default());
6717
6718        // A detached HEAD reports no branch (and thus no sync), but the repo
6719        // identity is still resolved from the common dir.
6720        let dir = tempfile::tempdir().unwrap();
6721        let repo = init_repo(dir.path());
6722        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6723        repo.set_head_detached(a).unwrap();
6724        let status = git_status(dir.path());
6725        assert_eq!(status.branch, None);
6726        // A detached HEAD has no branch but *does* have a commit — the SHA is
6727        // resolved before the branch filter, so it survives here (#1337).
6728        assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
6729        assert_eq!(status.ahead, None);
6730        assert_eq!(status.behind, None);
6731        // A detached HEAD has no branch, so there is no upstream to resolve
6732        // either — the branch filter returns before the wrap (#1344).
6733        assert_eq!(status.upstream_sha, None);
6734        assert_eq!(
6735            status.main_repo.as_deref(),
6736            dir.path().file_name().and_then(|n| n.to_str())
6737        );
6738        assert!(!status.is_worktree);
6739    }
6740
6741    // --- Lazy ahead/behind (#1306) -----------------------------------------
6742
6743    #[test]
6744    fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
6745        // The same repo `git_status` reports 1/1 for. The cheap variant used by
6746        // the streamed tree snapshot still reads the branch and repo identity, but
6747        // leaves ahead/behind absent — divergence is now lazy (#1306).
6748        let dir = tempfile::tempdir().unwrap();
6749        let repo = diverging_repo(dir.path());
6750        let status = git_status_cheap(dir.path());
6751        assert_eq!(status.branch.as_deref(), Some("main"));
6752        assert_eq!(status.ahead, None);
6753        assert_eq!(status.behind, None);
6754        assert_eq!(
6755            status.main_repo.as_deref(),
6756            dir.path().file_name().and_then(|n| n.to_str())
6757        );
6758        // The SHA rides the *cheap* path deliberately: it is a refs read, not a
6759        // revwalk, and it is what makes a new commit a snapshot delta (#1337).
6760        let head = repo.head().unwrap().target().unwrap();
6761        assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
6762    }
6763
6764    // --- HEAD SHA on the snapshot (#1337) ----------------------------------
6765
6766    #[test]
6767    fn git_status_head_sha_tracks_new_commits() {
6768        // The regression #1337 turns on: a commit must change the status the
6769        // snapshot is built from. Before the SHA rode the payload, committing
6770        // changed nothing on the wire, the server's diff dropped the identical
6771        // snapshot, and no client re-rendered — so a badge computed for the old
6772        // head survived the push that invalidated it.
6773        let dir = tempfile::tempdir().unwrap();
6774        let repo = init_repo(dir.path());
6775        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6776        repo.set_head("refs/heads/main").unwrap();
6777        let before = git_status_cheap(dir.path());
6778        assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
6779
6780        let head = repo.find_commit(a).unwrap();
6781        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
6782        let after = git_status_cheap(dir.path());
6783        assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
6784        assert_ne!(before.head_sha, after.head_sha);
6785        // The branch is unchanged — the SHA is the *only* thing that moved, which
6786        // is exactly why its absence made the push invisible.
6787        assert_eq!(before.branch, after.branch);
6788    }
6789
6790    // --- Upstream SHA on the snapshot (#1344) ------------------------------
6791
6792    /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
6793    /// does, and all of what it does: the local branch and HEAD do not move. Lets
6794    /// these tests exercise a push with no network and no second repo.
6795    fn simulate_push(repo: &Repository, oid: git2::Oid) {
6796        repo.reference("refs/remotes/origin/main", oid, true, "push")
6797            .unwrap();
6798    }
6799
6800    #[test]
6801    fn git_status_upstream_sha_tracks_a_push() {
6802        // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
6803        // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
6804        // remote-tracking ref, so before this field rode the payload every wire
6805        // field was byte-identical across the push, the server's diff dropped the
6806        // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
6807        // never re-asked — the row showed `↑1 ↓0` forever.
6808        let dir = tempfile::tempdir().unwrap();
6809        let repo = diverging_repo(dir.path());
6810        let before = git_status(dir.path());
6811        assert_eq!(before.ahead, Some(1));
6812        assert_eq!(before.behind, Some(1));
6813
6814        let head = repo.head().unwrap().target().unwrap();
6815        simulate_push(&repo, head);
6816        let after = git_status(dir.path());
6817
6818        // The upstream now names the pushed commit, and the counts agree.
6819        assert_eq!(
6820            after.upstream_sha.as_deref(),
6821            Some(head.to_string().as_str())
6822        );
6823        assert_ne!(before.upstream_sha, after.upstream_sha);
6824        assert_eq!(after.ahead, Some(0));
6825        assert_eq!(after.behind, Some(0));
6826        // Nothing else moved — which is the whole point. A push leaves the branch
6827        // and the local head exactly where they were, so `upstream_sha` is the
6828        // only signal a client could possibly notice.
6829        assert_eq!(before.branch, after.branch);
6830        assert_eq!(before.head_sha, after.head_sha);
6831    }
6832
6833    #[test]
6834    fn git_status_cheap_reports_upstream_sha() {
6835        // The crux: the field has to ride the *cheap* path, since that is the one
6836        // the streamed snapshot is built from. Costing a config lookup and a refs
6837        // read — no revwalk — it clears the bar #1306 set, unlike the divergence
6838        // walk still absent here.
6839        let dir = tempfile::tempdir().unwrap();
6840        let repo = diverging_repo(dir.path());
6841        let status = git_status_cheap(dir.path());
6842        let upstream = repo
6843            .find_branch("origin/main", git2::BranchType::Remote)
6844            .unwrap()
6845            .get()
6846            .target()
6847            .unwrap();
6848        assert_eq!(
6849            status.upstream_sha.as_deref(),
6850            Some(upstream.to_string().as_str())
6851        );
6852        assert_eq!(status.ahead, None);
6853        assert_eq!(status.behind, None);
6854    }
6855
6856    #[test]
6857    fn folder_divergence_computes_ahead_behind_and_degrades() {
6858        // A diverging tracking branch → the on-demand walk reports (ahead, behind).
6859        let dir = tempfile::tempdir().unwrap();
6860        let _repo = diverging_repo(dir.path());
6861        assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6862
6863        // A branch with no upstream → None (the tree renders no sync indicator).
6864        let no_up = tempfile::tempdir().unwrap();
6865        let repo = init_repo(no_up.path());
6866        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6867        repo.set_head("refs/heads/main").unwrap();
6868        assert_eq!(folder_divergence(no_up.path()).ahead_behind, None);
6869
6870        // A detached HEAD and a plain (non-repo) directory → None.
6871        let detached = tempfile::tempdir().unwrap();
6872        let drepo = init_repo(detached.path());
6873        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6874        drepo.set_head_detached(a).unwrap();
6875        assert_eq!(folder_divergence(detached.path()).ahead_behind, None);
6876        let plain = tempfile::tempdir().unwrap();
6877        assert_eq!(folder_divergence(plain.path()).ahead_behind, None);
6878    }
6879
6880    // --- Lazy main-branch behind (#1457) ------------------------------------
6881
6882    #[test]
6883    fn folder_divergence_computes_main_behind_and_degrades() {
6884        // No own upstream, but a resolvable `origin/main` (via the common-names
6885        // fallback — no `origin/HEAD` symref is set) that the branch is
6886        // genuinely behind.
6887        let dir = tempfile::tempdir().unwrap();
6888        let _repo = behind_main_no_upstream_repo(dir.path());
6889        assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
6890
6891        // A detached HEAD and a plain (non-repo) directory → None.
6892        let detached = tempfile::tempdir().unwrap();
6893        let drepo = init_repo(detached.path());
6894        let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
6895        drepo.set_head_detached(a).unwrap();
6896        assert_eq!(folder_divergence(detached.path()).main_behind, None);
6897        let plain = tempfile::tempdir().unwrap();
6898        assert_eq!(folder_divergence(plain.path()).main_behind, None);
6899    }
6900
6901    #[test]
6902    fn folder_divergence_skips_main_behind_when_own_upstream_is_the_default_branch() {
6903        // `diverging_repo` checks out `main` tracking `origin/main` itself — the
6904        // common case — so even though it's genuinely 1 behind, `main_behind`
6905        // stays silent: `ahead_behind`'s `behind` already reports this
6906        // exact divergence.
6907        let dir = tempfile::tempdir().unwrap();
6908        let _repo = diverging_repo(dir.path());
6909        assert_eq!(folder_divergence(dir.path()).main_behind, None);
6910    }
6911
6912    #[test]
6913    fn folder_divergence_has_no_main_behind_without_a_resolvable_default_branch() {
6914        // No `origin` remote-tracking refs at all (no symref, no common names).
6915        let dir = tempfile::tempdir().unwrap();
6916        let repo = init_repo(dir.path());
6917        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6918        repo.set_head("refs/heads/main").unwrap();
6919        assert_eq!(folder_divergence(dir.path()).main_behind, None);
6920    }
6921
6922    #[test]
6923    fn folder_divergence_reports_independent_main_behind_and_ahead_behind_counts() {
6924        let dir = tempfile::tempdir().unwrap();
6925        let repo = init_repo(dir.path());
6926        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
6927        let base_commit = repo.find_commit(base).unwrap();
6928
6929        // origin/main advances 3 commits past the shared base.
6930        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
6931        let m1_commit = repo.find_commit(m1).unwrap();
6932        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
6933        let m2_commit = repo.find_commit(m2).unwrap();
6934        let m3 = empty_commit(&repo, None, &[&m2_commit], "m3");
6935        repo.reference("refs/remotes/origin/main", m3, true, "origin main")
6936            .unwrap();
6937
6938        // `feature` branches off the shared base and diverges 1 ahead / 1
6939        // behind its own upstream `origin/feature`.
6940        let of = empty_commit(&repo, None, &[&base_commit], "origin-feature");
6941        repo.reference("refs/remotes/origin/feature", of, true, "origin feature")
6942            .unwrap();
6943        empty_commit(
6944            &repo,
6945            Some("refs/heads/feature"),
6946            &[&base_commit],
6947            "local-feature",
6948        );
6949        drop(base_commit);
6950        drop(m1_commit);
6951        drop(m2_commit);
6952
6953        repo.set_head("refs/heads/feature").unwrap();
6954        let mut cfg = repo.config().unwrap();
6955        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
6956            .unwrap();
6957        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
6958            .unwrap();
6959        cfg.set_str("branch.feature.remote", "origin").unwrap();
6960        cfg.set_str("branch.feature.merge", "refs/heads/feature")
6961            .unwrap();
6962
6963        assert_eq!(folder_divergence(dir.path()).ahead_behind, Some((1, 1)));
6964        assert_eq!(folder_divergence(dir.path()).main_behind, Some(3));
6965    }
6966
6967    // --- One Repository per repo across a batch's worktrees (#2121) ----------
6968
6969    /// A repository with linked worktrees covering every HEAD and upstream shape
6970    /// the `ahead-behind` op meets, so the shared path can be held to
6971    /// [`folder_divergence`] on each.
6972    ///
6973    /// `main` is 1 ahead of / 1 behind its upstream `origin/main` (one commit past
6974    /// the shared base locally, a different one remotely).
6975    struct SharedCorpus {
6976        _main_dir: tempfile::TempDir,
6977        _wts_dir: tempfile::TempDir,
6978        repo: Repository,
6979        main: PathBuf,
6980        wts: PathBuf,
6981        tip: git2::Oid,
6982        origin_main: git2::Oid,
6983    }
6984
6985    impl SharedCorpus {
6986        fn wt(&self, name: &str) -> PathBuf {
6987            self.wts.join(name)
6988        }
6989
6990        /// Points `branch` at `upstream_ref` the way `git branch -u` does.
6991        fn track(&self, branch: &str, upstream_ref: &str) {
6992            let mut cfg = self.repo.config().unwrap();
6993            cfg.set_str(&format!("branch.{branch}.remote"), "origin")
6994                .unwrap();
6995            cfg.set_str(&format!("branch.{branch}.merge"), upstream_ref)
6996                .unwrap();
6997        }
6998
6999        /// Every folder the op might be asked about, with a name for failures.
7000        fn cases(&self) -> Vec<(&'static str, PathBuf)> {
7001            vec![
7002                ("main checkout", self.main.clone()),
7003                ("tracked", self.wt("tracked")),
7004                (
7005                    "tracked, from a subdirectory",
7006                    self.wt("tracked").join("sub"),
7007                ),
7008                ("untracked", self.wt("untracked")),
7009                ("on the default branch", self.wt("on-default")),
7010                ("dangling upstream", self.wt("dangling")),
7011                ("detached", self.wt("detached")),
7012                ("unborn", self.wt("unborn")),
7013                ("deleted directory", self.wt("deleted")),
7014                ("never existed", self.wts.join("never-existed")),
7015            ]
7016        }
7017    }
7018
7019    fn shared_corpus() -> SharedCorpus {
7020        let main_dir = tempfile::tempdir().unwrap();
7021        let wts_dir = tempfile::tempdir().unwrap();
7022        let repo = diverging_repo(main_dir.path());
7023        let tip = repo.refname_to_id("refs/heads/main").unwrap();
7024        let origin_main = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7025        let base = repo.find_commit(tip).unwrap().parent_id(0).unwrap();
7026        let corpus = SharedCorpus {
7027            main: main_dir.path().to_path_buf(),
7028            wts: wts_dir.path().to_path_buf(),
7029            _main_dir: main_dir,
7030            _wts_dir: wts_dir,
7031            repo,
7032            tip,
7033            origin_main,
7034        };
7035
7036        // Behind its own upstream by nothing, ahead of it by one commit.
7037        add_worktree(&corpus.repo, tip, &corpus.wt("tracked"), "tracked");
7038        corpus
7039            .repo
7040            .reference("refs/remotes/origin/tracked", base, true, "origin tracked")
7041            .unwrap();
7042        corpus.track("tracked", "refs/heads/tracked");
7043        std::fs::create_dir(corpus.wt("tracked").join("sub")).unwrap();
7044
7045        // No upstream at all, but behind `origin/main`.
7046        add_worktree(&corpus.repo, tip, &corpus.wt("untracked"), "untracked");
7047
7048        // Its upstream *is* the default branch, so `main_behind` stays silent.
7049        add_worktree(&corpus.repo, base, &corpus.wt("on-default"), "on-default");
7050        corpus.track("on-default", "refs/heads/main");
7051
7052        // An upstream configured but never fetched.
7053        add_worktree(&corpus.repo, tip, &corpus.wt("dangling"), "dangling");
7054        corpus.track("dangling", "refs/heads/gone");
7055
7056        add_worktree(&corpus.repo, tip, &corpus.wt("detached"), "detached");
7057        Repository::open(corpus.wt("detached"))
7058            .unwrap()
7059            .set_head_detached(tip)
7060            .unwrap();
7061
7062        add_worktree(&corpus.repo, tip, &corpus.wt("unborn"), "unborn");
7063        Repository::open(corpus.wt("unborn"))
7064            .unwrap()
7065            .set_head("refs/heads/not-yet")
7066            .unwrap();
7067
7068        // Registered with the main repo, but its directory is gone.
7069        add_worktree(&corpus.repo, tip, &corpus.wt("deleted"), "deleted");
7070        std::fs::remove_dir_all(corpus.wt("deleted")).unwrap();
7071        corpus
7072    }
7073
7074    /// The core guarantee: for every shape of worktree, reading it through a
7075    /// handle at the common dir gives exactly what a `Repository` rooted at the
7076    /// worktree gives. Absolute values are pinned too, so the two cannot agree by
7077    /// both being wrong.
7078    #[test]
7079    fn shared_path_matches_the_discover_path_for_every_worktree_shape() {
7080        let corpus = shared_corpus();
7081        let pool = shared_repo::RepoPool::new(4);
7082
7083        for (case, path) in corpus.cases() {
7084            assert_eq!(
7085                shared_repo::divergence(&pool, &path),
7086                Some(folder_divergence(&path)),
7087                "{case}"
7088            );
7089        }
7090
7091        let expect = |name: &str, ahead_behind, main_behind| {
7092            assert_eq!(
7093                folder_divergence_shared(&pool, &corpus.wt(name)),
7094                Divergence {
7095                    ahead_behind,
7096                    main_behind,
7097                    ..Divergence::default()
7098                },
7099                "{name}"
7100            );
7101        };
7102        expect("tracked", Some((1, 0)), Some(1));
7103        expect("untracked", None, Some(1));
7104        expect("on-default", Some((0, 1)), None);
7105        expect("dangling", None, Some(1));
7106        for silent in ["detached", "unborn", "deleted"] {
7107            expect(silent, None, None);
7108        }
7109        // The main checkout and a subdirectory resolve like their own roots.
7110        assert_eq!(
7111            folder_divergence_shared(&pool, &corpus.main),
7112            Divergence {
7113                ahead_behind: Some((1, 1)),
7114                main_behind: None,
7115                ..Divergence::default()
7116            }
7117        );
7118        assert_eq!(
7119            folder_divergence_shared(&pool, &corpus.wt("tracked").join("sub")),
7120            folder_divergence_shared(&pool, &corpus.wt("tracked"))
7121        );
7122        // Every worktree of the repo, and the main checkout, shared one handle.
7123        assert_eq!(pool.opens(), 1);
7124    }
7125
7126    /// A shallow repository is never answered from a shared handle: libgit2 reads a
7127    /// repo's shallow grafts when it opens it, so a handle opened before the repo
7128    /// became shallow would walk the full history while a fresh open walks the cut
7129    /// one. The pool declines the repo, and the answer comes from the per-worktree
7130    /// open — including for a handle opened while the repo was still complete.
7131    ///
7132    /// That open reports the repository's shallowness for every worktree (#2147),
7133    /// though libgit2 only finds the marker from a handle at the common dir: git
7134    /// keeps `shallow` there, so a linked worktree's own handle neither sees it nor
7135    /// applies the cut. The marker here cuts the tip's parent, so each count is the
7136    /// one against the cut history (`main`'s tip is a root, and the shared base is
7137    /// reachable only from `origin/main`), not the one an uncut walk would give.
7138    #[test]
7139    fn a_shallow_repository_is_answered_by_the_discover_path() {
7140        let corpus = shared_corpus();
7141        let pool = shared_repo::RepoPool::new(4);
7142        // Complete so far: the shared path answers, from one handle.
7143        assert_eq!(
7144            shared_repo::divergence(&pool, &corpus.wt("tracked")),
7145            Some(folder_divergence(&corpus.wt("tracked")))
7146        );
7147        assert!(!folder_divergence_shared(&pool, &corpus.main).shallow);
7148        assert_eq!(pool.opens(), 1);
7149
7150        std::fs::write(
7151            corpus.repo.path().join("shallow"),
7152            format!("{}\n", corpus.tip),
7153        )
7154        .unwrap();
7155
7156        for (case, path) in corpus.cases() {
7157            // The repo is shallow now, so the shared path steps aside for every
7158            // folder inside it; the folders outside it have nothing to decline.
7159            let in_repo = !matches!(case, "deleted directory" | "never existed");
7160            assert_eq!(
7161                shared_repo::divergence(&pool, &path).is_none(),
7162                in_repo,
7163                "{case}"
7164            );
7165            assert_eq!(
7166                folder_divergence_shared(&pool, &path),
7167                folder_divergence(&path),
7168                "{case}"
7169            );
7170        }
7171        // Every worktree is shallow, the linked ones included, and counted against
7172        // the cut history.
7173        let shallow = |ahead_behind, main_behind| Divergence {
7174            ahead_behind,
7175            main_behind,
7176            shallow: true,
7177        };
7178        assert_eq!(
7179            folder_divergence_shared(&pool, &corpus.main),
7180            shallow(Some((1, 2)), None)
7181        );
7182        for (name, expected) in [
7183            ("tracked", shallow(Some((1, 1)), Some(2))),
7184            ("untracked", shallow(None, Some(2))),
7185            ("on-default", shallow(Some((0, 1)), None)),
7186            ("dangling", shallow(None, Some(2))),
7187            ("detached", shallow(None, None)),
7188            ("unborn", shallow(None, None)),
7189        ] {
7190            assert_eq!(
7191                folder_divergence_shared(&pool, &corpus.wt(name)),
7192                expected,
7193                "{name}"
7194            );
7195        }
7196        // No handle was opened for it after the marker appeared.
7197        assert_eq!(pool.opens(), 1);
7198    }
7199
7200    /// A bare main repository has no working tree of its own, but its linked
7201    /// worktrees are read through it the same way.
7202    #[test]
7203    fn shared_path_serves_the_worktrees_of_a_bare_repository() {
7204        let bare_dir = tempfile::tempdir().unwrap();
7205        let repo = Repository::init_bare(bare_dir.path()).unwrap();
7206        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7207        repo.set_head("refs/heads/main").unwrap();
7208        let a_commit = repo.find_commit(a).unwrap();
7209        let c = empty_commit(&repo, None, &[&a_commit], "C");
7210        repo.reference("refs/remotes/origin/main", c, true, "origin main")
7211            .unwrap();
7212        drop(a_commit);
7213        let wts = tempfile::tempdir().unwrap();
7214        let wt = wts.path().join("feature");
7215        add_worktree(&repo, a, &wt, "feature");
7216
7217        let pool = shared_repo::RepoPool::new(4);
7218        for path in [wt.as_path(), bare_dir.path()] {
7219            assert_eq!(
7220                shared_repo::divergence(&pool, path),
7221                Some(folder_divergence(path)),
7222                "{}",
7223                path.display()
7224            );
7225        }
7226        // Behind `origin/main` by its one commit, from the worktree and the bare
7227        // repo alike (both are on `main`'s history with no upstream of their own).
7228        assert_eq!(folder_divergence_shared(&pool, &wt).main_behind, Some(1));
7229        assert_eq!(pool.opens(), 1);
7230    }
7231
7232    /// With `extensions.worktreeConfig` a worktree's own `config.worktree` can
7233    /// override its branch's upstream. libgit2 honours that on the discover path
7234    /// and a handle at the common dir cannot see it, so the shared path must step
7235    /// aside rather than answer from the wrong config.
7236    #[test]
7237    fn shared_path_defers_to_discover_when_config_is_per_worktree() {
7238        let corpus = shared_corpus();
7239        let wt = corpus.wt("tracked");
7240        corpus
7241            .repo
7242            .config()
7243            .unwrap()
7244            .set_bool("extensions.worktreeConfig", true)
7245            .unwrap();
7246        std::fs::write(
7247            corpus.repo.path().join("worktrees/tracked/config.worktree"),
7248            "[branch \"tracked\"]\n\tmerge = refs/heads/elsewhere\n",
7249        )
7250        .unwrap();
7251        // The override really changes the answer, so this test is not vacuous.
7252        assert_eq!(folder_divergence(&wt).ahead_behind, None);
7253
7254        let pool = shared_repo::RepoPool::new(4);
7255        assert_eq!(shared_repo::divergence(&pool, &wt), None);
7256        assert_eq!(folder_divergence_shared(&pool, &wt), folder_divergence(&wt));
7257    }
7258
7259    /// A conditional include is evaluated against the repository it is loaded for:
7260    /// libgit2 matches `onbranch:` against that repository's HEAD and `gitdir:`
7261    /// against its gitdir. A handle at the common dir would match both against the
7262    /// main working tree and the common dir instead, so the shared path must step
7263    /// aside for a repo that has one.
7264    #[test]
7265    fn shared_path_defers_to_discover_when_a_conditional_include_exists() {
7266        let corpus = shared_corpus();
7267        let common = std::fs::canonicalize(corpus.repo.path()).unwrap();
7268        // Gives `untracked` an upstream, but only while its own HEAD is `untracked`.
7269        std::fs::write(
7270            common.join("onbranch.cfg"),
7271            "[branch \"untracked\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7272        )
7273        .unwrap();
7274        // Re-points `tracked`, but only for a repository whose gitdir is a
7275        // worktree's, under `<common>/worktrees/`.
7276        std::fs::write(
7277            common.join("gitdir.cfg"),
7278            "[branch \"tracked\"]\n\tmerge = refs/heads/elsewhere\n",
7279        )
7280        .unwrap();
7281        let mut cfg = corpus.repo.config().unwrap();
7282        cfg.set_str("includeIf.onbranch:untracked.path", "onbranch.cfg")
7283            .unwrap();
7284        cfg.set_str(
7285            &format!("includeIf.gitdir:{}/worktrees/.path", common.display()),
7286            "gitdir.cfg",
7287        )
7288        .unwrap();
7289
7290        // Both includes really change what a repository rooted at the worktree
7291        // reports, so this test is not vacuous.
7292        assert_eq!(
7293            folder_divergence(&corpus.wt("untracked")).ahead_behind,
7294            Some((1, 1))
7295        );
7296        assert_eq!(folder_divergence(&corpus.wt("tracked")).ahead_behind, None);
7297
7298        let pool = shared_repo::RepoPool::new(4);
7299        for name in ["untracked", "tracked"] {
7300            let wt = corpus.wt(name);
7301            assert_eq!(shared_repo::divergence(&pool, &wt), None, "{name}");
7302            assert_eq!(
7303                folder_divergence_shared(&pool, &wt),
7304                folder_divergence(&wt),
7305                "{name}"
7306            );
7307        }
7308    }
7309
7310    /// A handle reused across computations must still see the repository as it is
7311    /// now: refs, the branch itself, and config can all change between two uses of
7312    /// one pooled handle, and the answer must follow.
7313    #[test]
7314    fn a_pooled_handle_sees_changes_made_between_uses() {
7315        let corpus = shared_corpus();
7316        let wt = corpus.wt("tracked");
7317        let pool = shared_repo::RepoPool::new(4);
7318        let ahead_behind = || shared_repo::divergence(&pool, &wt).unwrap().ahead_behind;
7319        assert_eq!(ahead_behind(), Some((1, 0)));
7320        let opens = pool.opens();
7321
7322        // A fetch moves the remote-tracking ref.
7323        corpus
7324            .repo
7325            .reference(
7326                "refs/remotes/origin/tracked",
7327                corpus.origin_main,
7328                true,
7329                "fetch",
7330            )
7331            .unwrap();
7332        assert_eq!(ahead_behind(), Some((1, 1)));
7333
7334        // A commit moves the branch.
7335        let tip = corpus.repo.find_commit(corpus.tip).unwrap();
7336        empty_commit(&corpus.repo, Some("refs/heads/tracked"), &[&tip], "D");
7337        assert_eq!(ahead_behind(), Some((2, 1)));
7338
7339        // A config edit re-points the upstream.
7340        corpus.track("tracked", "refs/heads/elsewhere");
7341        assert_eq!(ahead_behind(), None);
7342
7343        assert_eq!(pool.opens(), opens, "every answer came from one handle");
7344        assert_eq!(
7345            shared_repo::divergence(&pool, &wt),
7346            Some(folder_divergence(&wt))
7347        );
7348    }
7349
7350    /// The acceptance criterion: a batch over many linked worktrees of one repo
7351    /// opens that repo once per concurrent computation, not once per worktree.
7352    #[tokio::test]
7353    async fn a_batch_of_linked_worktrees_opens_their_repo_once_per_concurrent_computation() {
7354        let main_dir = tempfile::tempdir().unwrap();
7355        let wts_dir = tempfile::tempdir().unwrap();
7356        let repo = diverging_repo(main_dir.path());
7357        let tip = repo.refname_to_id("refs/heads/main").unwrap();
7358        let paths: Vec<PathBuf> = (0..8)
7359            .map(|i| {
7360                let name = format!("w{i}");
7361                let path = wts_dir.path().join(&name);
7362                add_worktree(&repo, tip, &path, &name);
7363                let mut cfg = repo.config().unwrap();
7364                cfg.set_str(&format!("branch.{name}.remote"), "origin")
7365                    .unwrap();
7366                cfg.set_str(&format!("branch.{name}.merge"), "refs/heads/main")
7367                    .unwrap();
7368                path
7369            })
7370            .collect();
7371
7372        for (limit, most_opens) in [(1, 1), (4, 4)] {
7373            let coordinator = AheadBehindCoordinator::with_concurrency(limit);
7374            // Holding the pool lets the test read its counter; the batch joins it.
7375            let pool = coordinator.pool();
7376            let reply = ahead_behind_results(&coordinator, paths.clone()).await;
7377
7378            assert!(
7379                (1..=most_opens).contains(&pool.opens()),
7380                "{} opens for 8 worktrees at concurrency {limit}",
7381                pool.opens()
7382            );
7383            for path in &paths {
7384                let entry = reply.get(path.display().to_string()).unwrap();
7385                assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7386                assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7387            }
7388            // With the batch done and the test's hold dropped, nothing is kept.
7389            let weak = Arc::downgrade(&pool);
7390            drop(pool);
7391            assert!(weak.upgrade().is_none());
7392        }
7393    }
7394
7395    /// End to end through the op: one reply names every worktree of the repo the
7396    /// way the discover path would, and omits those it has nothing to say about.
7397    #[tokio::test]
7398    async fn ahead_behind_op_answers_every_worktree_shape_of_one_repo() {
7399        let corpus = shared_corpus();
7400        let svc = WorktreesService::new();
7401        let paths: Vec<String> = corpus
7402            .cases()
7403            .iter()
7404            .map(|(_, path)| path.display().to_string())
7405            .collect();
7406
7407        let reply = svc
7408            .handle("ahead-behind", json!({ "paths": paths }))
7409            .await
7410            .unwrap();
7411        let results = reply.get("results").unwrap();
7412
7413        let numbers = |name: &str| {
7414            let entry = results.get(corpus.wt(name).display().to_string())?;
7415            let field = |key| entry.get(key).and_then(Value::as_u64);
7416            Some((field("ahead"), field("behind"), field("main_behind")))
7417        };
7418        assert_eq!(numbers("tracked"), Some((Some(1), Some(0), Some(1))));
7419        assert_eq!(numbers("untracked"), Some((None, None, Some(1))));
7420        assert_eq!(numbers("on-default"), Some((Some(0), Some(1), None)));
7421        assert_eq!(numbers("dangling"), Some((None, None, Some(1))));
7422        for omitted in ["detached", "unborn", "deleted", "never-existed"] {
7423            assert_eq!(numbers(omitted), None, "{omitted}");
7424        }
7425        let main = results
7426            .get(corpus.main.display().to_string())
7427            .and_then(|entry| entry.get("ahead"))
7428            .and_then(Value::as_u64);
7429        assert_eq!(main, Some(1));
7430    }
7431
7432    #[tokio::test]
7433    async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
7434        let diverging = tempfile::tempdir().unwrap();
7435        let _d = diverging_repo(diverging.path());
7436        let no_up = tempfile::tempdir().unwrap();
7437        let repo = init_repo(no_up.path());
7438        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7439        repo.set_head("refs/heads/main").unwrap();
7440
7441        let svc = WorktreesService::new();
7442        let diverging_path = diverging.path().display().to_string();
7443        let no_up_path = no_up.path().display().to_string();
7444        let reply = svc
7445            .handle(
7446                "ahead-behind",
7447                json!({ "paths": [&diverging_path, &no_up_path] }),
7448            )
7449            .await
7450            .unwrap();
7451        let results = reply.get("results").unwrap();
7452        // The diverging worktree carries its counts, keyed by the requested path.
7453        // `diverging_repo` checks out `main` tracking `origin/main` itself, so
7454        // `main_behind` is skipped — `behind` already reports this divergence.
7455        let d = results.get(diverging_path.as_str()).unwrap();
7456        assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
7457        assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
7458        assert!(d.get("main_behind").is_none(), "{d:?}");
7459        // The no-upstream worktree has no `origin` remote-tracking refs at all,
7460        // so neither `ahead`/`behind` nor `main_behind` resolves — the row is
7461        // omitted entirely, not reported as zero.
7462        assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
7463
7464        // A missing/empty `paths` list yields an empty results object, not an error.
7465        let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
7466        assert_eq!(empty.get("results"), Some(&json!({})));
7467    }
7468
7469    /// The adapter forwards a daemon-level accept outage to its registry (#2111),
7470    /// which is covered in depth in `crate::worktrees`; here it just has to reach it
7471    /// and leave a live window live.
7472    #[tokio::test]
7473    async fn credit_accept_outage_reaches_the_registry_without_dropping_a_live_window() {
7474        let svc = WorktreesService::new();
7475        svc.handle(
7476            "register",
7477            json!({ "key": "w", "folders": [], "repo": "x" }),
7478        )
7479        .await
7480        .unwrap();
7481
7482        svc.credit_accept_outage(Duration::from_secs(5));
7483
7484        assert_eq!(svc.registry.list().len(), 1);
7485    }
7486
7487    /// Every window asks about the same worktrees at once (#2111): all of those
7488    /// requests must get the right answer, whether they shared a computation or not,
7489    /// including a request that names one worktree twice.
7490    #[tokio::test]
7491    async fn concurrent_ahead_behind_requests_all_get_the_same_correct_answer() {
7492        let dir = tempfile::tempdir().unwrap();
7493        let _repo = diverging_repo(dir.path());
7494        let svc = WorktreesService::new();
7495        let path = dir.path().display().to_string();
7496
7497        let replies = futures::future::join_all(
7498            (0..16).map(|_| svc.handle("ahead-behind", json!({ "paths": [&path, &path] }))),
7499        )
7500        .await;
7501
7502        for reply in replies {
7503            let reply = reply.unwrap();
7504            let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7505            assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7506            assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7507        }
7508    }
7509
7510    #[tokio::test]
7511    async fn ahead_behind_op_includes_a_path_with_only_main_behind_and_no_upstream() {
7512        let dir = tempfile::tempdir().unwrap();
7513        let _repo = behind_main_no_upstream_repo(dir.path());
7514        let svc = WorktreesService::new();
7515        let path = dir.path().display().to_string();
7516        let reply = svc
7517            .handle("ahead-behind", json!({ "paths": [&path] }))
7518            .await
7519            .unwrap();
7520        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7521        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(1));
7522        // No own upstream at all → no `ahead`/`behind` keys, just `main_behind`.
7523        assert!(entry.get("ahead").is_none(), "{entry:?}");
7524        assert!(entry.get("behind").is_none(), "{entry:?}");
7525    }
7526
7527    #[tokio::test]
7528    async fn ahead_behind_op_flags_a_shallow_repository_and_only_that() {
7529        // A shallow clone's counts depend on how deep it currently is, which no
7530        // commit id records, so a client memoizing by ids has to be told (#2120).
7531        let svc = WorktreesService::new();
7532        let complete = tempfile::tempdir().unwrap();
7533        let _repo = diverging_repo(complete.path());
7534        let shallow = tempfile::tempdir().unwrap();
7535        let shallow_repo = diverging_repo(shallow.path());
7536        // `.git/shallow` is what makes git (and libgit2) treat the clone as shallow.
7537        let base = shallow_repo
7538            .head()
7539            .unwrap()
7540            .peel_to_commit()
7541            .unwrap()
7542            .parent_id(0)
7543            .unwrap();
7544        std::fs::write(shallow_repo.path().join("shallow"), format!("{base}\n")).unwrap();
7545        assert!(shallow_repo.is_shallow());
7546
7547        let complete_path = complete.path().display().to_string();
7548        let shallow_path = shallow.path().display().to_string();
7549        let reply = svc
7550            .handle(
7551                "ahead-behind",
7552                json!({ "paths": [&complete_path, &shallow_path] }),
7553            )
7554            .await
7555            .unwrap();
7556        let results = reply.get("results").unwrap();
7557
7558        let shallow_entry = results.get(shallow_path.as_str()).unwrap();
7559        assert_eq!(shallow_entry.get("shallow"), Some(&json!(true)));
7560        // The counts are still reported — the flag is a caveat, not a refusal.
7561        assert_eq!(shallow_entry.get("ahead").and_then(Value::as_u64), Some(1));
7562        // Wire-compat: a complete clone sends no key at all, so an older client sees
7563        // exactly the row it saw before.
7564        let complete_entry = results.get(complete_path.as_str()).unwrap();
7565        assert!(
7566            complete_entry.get("shallow").is_none(),
7567            "{complete_entry:?}"
7568        );
7569        assert_eq!(complete_entry.get("ahead").and_then(Value::as_u64), Some(1));
7570    }
7571
7572    /// libgit2 finds the shallow marker only from a handle at the common dir, so a
7573    /// linked worktree of a shallow clone used to go unflagged — and a client that
7574    /// memoizes by commit ids would then cache a row that deepening the clone
7575    /// changes (#2147). It is flagged now, and still has its counts.
7576    #[tokio::test]
7577    async fn ahead_behind_op_flags_a_linked_worktree_of_a_shallow_clone() {
7578        let svc = WorktreesService::new();
7579        let corpus = shared_corpus();
7580        std::fs::write(
7581            corpus.repo.path().join("shallow"),
7582            format!("{}\n", corpus.tip),
7583        )
7584        .unwrap();
7585        let tracked = corpus.wt("tracked").display().to_string();
7586
7587        let reply = svc
7588            .handle("ahead-behind", json!({ "paths": [&tracked] }))
7589            .await
7590            .unwrap();
7591
7592        let entry = reply.get("results").unwrap().get(tracked.as_str()).unwrap();
7593        assert_eq!(entry.get("shallow"), Some(&json!(true)), "{entry:?}");
7594        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(1));
7595        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(1));
7596        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7597    }
7598
7599    #[tokio::test]
7600    async fn ahead_behind_op_reports_main_behind_alongside_an_in_sync_own_upstream() {
7601        // `release` stays perfectly in sync with its own upstream
7602        // `origin/release`, while `origin/main` has independently advanced 2
7603        // commits past their shared base — `main_behind` must still fold in
7604        // even though `ahead`/`behind` are both zero.
7605        let dir = tempfile::tempdir().unwrap();
7606        let repo = init_repo(dir.path());
7607        let base = empty_commit(&repo, Some("refs/heads/main"), &[], "base");
7608        let base_commit = repo.find_commit(base).unwrap();
7609
7610        let m1 = empty_commit(&repo, None, &[&base_commit], "m1");
7611        let m1_commit = repo.find_commit(m1).unwrap();
7612        let m2 = empty_commit(&repo, None, &[&m1_commit], "m2");
7613        repo.reference("refs/remotes/origin/main", m2, true, "origin main")
7614            .unwrap();
7615
7616        let r = empty_commit(
7617            &repo,
7618            Some("refs/heads/release"),
7619            &[&base_commit],
7620            "release",
7621        );
7622        repo.reference("refs/remotes/origin/release", r, true, "origin release")
7623            .unwrap();
7624        drop(base_commit);
7625        drop(m1_commit);
7626
7627        repo.set_head("refs/heads/release").unwrap();
7628        let mut cfg = repo.config().unwrap();
7629        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
7630            .unwrap();
7631        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
7632            .unwrap();
7633        cfg.set_str("branch.release.remote", "origin").unwrap();
7634        cfg.set_str("branch.release.merge", "refs/heads/release")
7635            .unwrap();
7636
7637        let svc = WorktreesService::new();
7638        let path = dir.path().display().to_string();
7639        let reply = svc
7640            .handle("ahead-behind", json!({ "paths": [&path] }))
7641            .await
7642            .unwrap();
7643        let entry = reply.get("results").unwrap().get(path.as_str()).unwrap();
7644        assert_eq!(entry.get("ahead").and_then(Value::as_u64), Some(0));
7645        assert_eq!(entry.get("behind").and_then(Value::as_u64), Some(0));
7646        assert_eq!(entry.get("main_behind").and_then(Value::as_u64), Some(2));
7647    }
7648
7649    #[tokio::test]
7650    async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
7651        // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
7652        let dir = tempfile::tempdir().unwrap();
7653        let _repo = diverging_repo(dir.path());
7654        let svc = WorktreesService::new();
7655        svc.handle(
7656            "register",
7657            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7658        )
7659        .await
7660        .unwrap();
7661
7662        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7663        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7664        let main_wt = &worktrees[0];
7665        // The cheap parts are present, but divergence is not — it is fetched
7666        // lazily via the `ahead-behind` op (#1306).
7667        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
7668        assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
7669        assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
7670    }
7671
7672    #[tokio::test]
7673    async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
7674        // The end-to-end shape of the #1337 freshness fix. The server pushes a
7675        // snapshot only when it differs from the last one
7676        // (`server.rs`: `if snap != last`), so anything invisible on the wire
7677        // cannot trigger a re-render. Committing must therefore move the payload.
7678        let dir = tempfile::tempdir().unwrap();
7679        let repo = init_repo(dir.path());
7680        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7681        repo.set_head("refs/heads/main").unwrap();
7682
7683        let svc = WorktreesService::new();
7684        svc.handle(
7685            "register",
7686            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7687        )
7688        .await
7689        .unwrap();
7690
7691        let before = svc.handle("tree", Value::Null).await.unwrap();
7692        let wt = &repos_of(&before)[0]["worktrees"][0];
7693        assert_eq!(
7694            wt.get("head_sha").and_then(Value::as_str),
7695            Some(a.to_string().as_str())
7696        );
7697
7698        // Commit again: same branch, same paths, same open windows — pre-#1337 the
7699        // snapshot was byte-identical here and the push was dropped.
7700        let head = repo.find_commit(a).unwrap();
7701        let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
7702        let after = svc.handle("tree", Value::Null).await.unwrap();
7703        assert_eq!(
7704            repos_of(&after)[0]["worktrees"][0]
7705                .get("head_sha")
7706                .and_then(Value::as_str),
7707            Some(b.to_string().as_str())
7708        );
7709        assert_ne!(before, after, "a commit must be a visible snapshot delta");
7710    }
7711
7712    #[tokio::test]
7713    async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
7714        // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
7715        // matching the payload's `skip_serializing_if` convention.
7716        let dir = tempfile::tempdir().unwrap();
7717        init_repo(dir.path());
7718        let svc = WorktreesService::new();
7719        svc.handle(
7720            "register",
7721            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7722        )
7723        .await
7724        .unwrap();
7725        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7726        assert!(wt.get("head_sha").is_none(), "{wt:?}");
7727    }
7728
7729    // --- Upstream SHA on the snapshot (#1344) ------------------------------
7730
7731    #[tokio::test]
7732    async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
7733        // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
7734        // moves neither the branch nor the local head, so `upstream_sha` is the
7735        // only field that can carry the news. Without it the snapshot serialised
7736        // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
7737        // window re-rendered, and the lazy ahead/behind was never re-fetched.
7738        let dir = tempfile::tempdir().unwrap();
7739        let repo = diverging_repo(dir.path());
7740        let svc = WorktreesService::new();
7741        svc.handle(
7742            "register",
7743            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7744        )
7745        .await
7746        .unwrap();
7747
7748        let before = svc.handle("tree", Value::Null).await.unwrap();
7749        let head = repo.head().unwrap().target().unwrap();
7750        assert_ne!(
7751            repos_of(&before)[0]["worktrees"][0]
7752                .get("upstream_sha")
7753                .and_then(Value::as_str),
7754            Some(head.to_string().as_str()),
7755            "the fixture must start un-pushed for this to prove anything"
7756        );
7757
7758        // Push: only `refs/remotes/origin/main` moves.
7759        simulate_push(&repo, head);
7760        let after = svc.handle("tree", Value::Null).await.unwrap();
7761        let wt = &repos_of(&after)[0]["worktrees"][0];
7762        assert_eq!(
7763            wt.get("upstream_sha").and_then(Value::as_str),
7764            Some(head.to_string().as_str())
7765        );
7766        // The head and branch are untouched across the push — so this delta rests
7767        // entirely on `upstream_sha`.
7768        assert_eq!(
7769            wt.get("head_sha").and_then(Value::as_str),
7770            repos_of(&before)[0]["worktrees"][0]
7771                .get("head_sha")
7772                .and_then(Value::as_str)
7773        );
7774        assert_ne!(before, after, "a push must be a visible snapshot delta");
7775    }
7776
7777    #[tokio::test]
7778    async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
7779        // Wire-compat, and the no-regression case: a branch tracking nothing sends
7780        // no key at all rather than a null, so an older client sees exactly the
7781        // payload it saw before and still renders no sync indicator.
7782        let dir = tempfile::tempdir().unwrap();
7783        let repo = init_repo(dir.path());
7784        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7785        repo.set_head("refs/heads/main").unwrap();
7786        let svc = WorktreesService::new();
7787        svc.handle(
7788            "register",
7789            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7790        )
7791        .await
7792        .unwrap();
7793        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
7794        assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
7795        // The head still rides, so this is specifically the upstream degrading.
7796        assert!(wt.get("head_sha").is_some(), "{wt:?}");
7797    }
7798
7799    // --- Default-branch tip on the snapshot (#2120) ------------------------
7800
7801    /// Advances `refs/remotes/origin/main` by one commit — what a `git fetch` that
7802    /// brings in new default-branch work does, and all of what it does.
7803    fn simulate_default_branch_fetch(repo: &Repository) -> git2::Oid {
7804        let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7805        let parent = repo.find_commit(tip).unwrap();
7806        let next = empty_commit(repo, None, &[&parent], "fetched");
7807        repo.reference("refs/remotes/origin/main", next, true, "fetch")
7808            .unwrap();
7809        next
7810    }
7811
7812    #[test]
7813    fn default_branch_tip_resolves_the_remote_default_branch_or_nothing() {
7814        // Resolved through the common-names fallback: no `origin/HEAD` symref.
7815        let dir = tempfile::tempdir().unwrap();
7816        let repo = behind_main_no_upstream_repo(dir.path());
7817        let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7818        assert_eq!(
7819            default_branch_tip(&repo),
7820            Some(("refs/remotes/origin/main".to_string(), tip))
7821        );
7822
7823        // The `origin/HEAD` symref outranks the common names, so a repo whose
7824        // default branch is `develop` publishes *that* tip even though `origin/main`
7825        // also exists.
7826        let develop = empty_commit(&repo, None, &[], "develop");
7827        repo.reference("refs/remotes/origin/develop", develop, true, "develop")
7828            .unwrap();
7829        repo.reference_symbolic(
7830            "refs/remotes/origin/HEAD",
7831            "refs/remotes/origin/develop",
7832            true,
7833            "set-head",
7834        )
7835        .unwrap();
7836        assert_eq!(
7837            default_branch_tip(&repo),
7838            Some(("refs/remotes/origin/develop".to_string(), develop))
7839        );
7840
7841        // No remote-tracking refs at all → nothing to publish.
7842        let bare = tempfile::tempdir().unwrap();
7843        let none = init_repo(bare.path());
7844        empty_commit(&none, Some("refs/heads/main"), &[], "A");
7845        assert_eq!(default_branch_tip(&none), None);
7846    }
7847
7848    #[test]
7849    fn main_behind_is_computed_against_the_published_tip() {
7850        // The property the extension's memo leans on: `main_behind` and `main_sha`
7851        // come from one resolver, so when the published tip moves the count moves
7852        // with it, and never the other way round.
7853        let dir = tempfile::tempdir().unwrap();
7854        let repo = behind_main_no_upstream_repo(dir.path());
7855        assert_eq!(folder_divergence(dir.path()).main_behind, Some(1));
7856        let before = default_branch_tip(&repo).unwrap().1;
7857
7858        let after = simulate_default_branch_fetch(&repo);
7859        assert_ne!(before, after);
7860        assert_eq!(default_branch_tip(&repo).unwrap().1, after);
7861        assert_eq!(folder_divergence(dir.path()).main_behind, Some(2));
7862    }
7863
7864    #[tokio::test]
7865    async fn tree_snapshot_carries_main_sha_so_a_default_branch_fetch_is_a_real_delta() {
7866        // The #1344 shape, one ref over. `main` tracks nothing and is behind
7867        // `origin/main`; a fetch that advances only that ref moves neither
7868        // `head_sha` nor `upstream_sha` (absent here), so before this field the
7869        // snapshot serialised byte-identically, the server's `if snap != last`
7870        // dropped the frame, and `main_behind` was never re-asked.
7871        let dir = tempfile::tempdir().unwrap();
7872        let repo = behind_main_no_upstream_repo(dir.path());
7873        let svc = WorktreesService::new();
7874        svc.handle(
7875            "register",
7876            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7877        )
7878        .await
7879        .unwrap();
7880
7881        let before = svc.handle("tree", Value::Null).await.unwrap();
7882        let tip = repo.refname_to_id("refs/remotes/origin/main").unwrap();
7883        assert_eq!(
7884            repos_of(&before)[0].get("main_sha").and_then(Value::as_str),
7885            Some(tip.to_string().as_str())
7886        );
7887
7888        let fetched = simulate_default_branch_fetch(&repo);
7889        let after = svc.handle("tree", Value::Null).await.unwrap();
7890        assert_eq!(
7891            repos_of(&after)[0].get("main_sha").and_then(Value::as_str),
7892            Some(fetched.to_string().as_str())
7893        );
7894        // Nothing a worktree row carries moved, so this delta rests entirely on
7895        // `main_sha`.
7896        assert_eq!(
7897            repos_of(&before)[0]["worktrees"],
7898            repos_of(&after)[0]["worktrees"]
7899        );
7900        assert_ne!(
7901            before, after,
7902            "a default-branch fetch must be a snapshot delta"
7903        );
7904    }
7905
7906    #[tokio::test]
7907    async fn tree_snapshot_omits_main_sha_without_a_default_branch() {
7908        // Wire-compat: no resolvable default branch sends no key at all rather than
7909        // a null, so an older client sees exactly the payload it saw before.
7910        let dir = tempfile::tempdir().unwrap();
7911        let repo = init_repo(dir.path());
7912        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7913        repo.set_head("refs/heads/main").unwrap();
7914        let svc = WorktreesService::new();
7915        svc.handle(
7916            "register",
7917            json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
7918        )
7919        .await
7920        .unwrap();
7921        let tree = svc.handle("tree", Value::Null).await.unwrap();
7922        assert!(repos_of(&tree)[0].get("main_sha").is_none(), "{tree:?}");
7923    }
7924
7925    // --- PR badge poller (#1337) -------------------------------------------
7926
7927    /// Writes an executable stub that ignores its arguments and prints `stdout`,
7928    /// standing in for `gh api graphql` so the poll loop is exercised offline.
7929    /// Returns the shim lock alongside the path: the caller **must** hold the
7930    /// guard until the poller has finished exec'ing the stub. Writing an
7931    /// executable and then `execve`ing it races every other thread that forks —
7932    /// the child inherits the still-open writable FD and the exec fails
7933    /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
7934    fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
7935        let guard = shim_lock();
7936        let path = dir.join("fake-gh");
7937        write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
7938        (path, guard)
7939    }
7940
7941    /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
7942    /// byte to a counter file before printing `stdout`. The returned counter path
7943    /// lets a test assert how many `gh` subprocesses actually ran, independent of
7944    /// the #1387 request-log counter — so the two can be compared (#1389).
7945    fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
7946        let guard = shim_lock();
7947        let path = dir.join("fake-gh");
7948        let counter = dir.join("gh-calls");
7949        write_exec_script(
7950            &path,
7951            &format!(
7952                "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
7953                counter = counter.display()
7954            ),
7955        );
7956        (path, guard, counter)
7957    }
7958
7959    /// The number of `gh` subprocesses the counting stub recorded (0 if it never
7960    /// ran) — the length of the counter file.
7961    fn gh_spawn_count(counter: &Path) -> usize {
7962        std::fs::read(counter).map_or(0, |b| b.len())
7963    }
7964
7965    /// The number of **successful** `kind: "gh"` records the #1387 choke point
7966    /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
7967    /// on the exit code matches the ground-truth counter (which is written when the
7968    /// stub *runs*), so a rare failed spawn cannot desync the two.
7969    fn counted_gh_records(log: &Path) -> usize {
7970        std::fs::read_to_string(log)
7971            .unwrap_or_default()
7972            .lines()
7973            .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
7974            .count()
7975    }
7976
7977    /// A repo with a GitHub origin and one commit on `main`.
7978    fn github_repo(dir: &Path) -> Repository {
7979        github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
7980    }
7981
7982    /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
7983    /// test can register **distinct** targets (owner/name) across windows.
7984    fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
7985        let repo = init_repo(dir);
7986        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7987        repo.set_head("refs/heads/main").unwrap();
7988        repo.remote("origin", url).unwrap();
7989        repo
7990    }
7991
7992    /// A pending badge whose verdict is about `head_oid`. The commit is explicit
7993    /// because the fold downgrades a badge naming a different commit than the
7994    /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
7995    /// wrong reason.
7996    fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
7997        PrBadge {
7998            number,
7999            is_draft: false,
8000            checks: PrCheckState::Pending,
8001            url: "u".into(),
8002            head_oid: head_oid.to_string(),
8003        }
8004    }
8005
8006    /// Wraps a badge as the cache's resolution value (#1370).
8007    fn pr(badge: PrBadge) -> PrResolution {
8008        PrResolution::Pr(badge)
8009    }
8010
8011    #[test]
8012    fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
8013        let snapshot = json!({"repos":[
8014            {
8015                "main_repo":"omni-dev",
8016                "github":{"owner":"rust-works","name":"omni-dev"},
8017                "root":"/r",
8018                // Enabled (#1376) — a not-polled repo contributes no targets (see
8019                // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
8020                "polling_enabled":true,
8021                // Two worktrees on the same branch must ask once, not twice.
8022                "worktrees":[
8023                    {"path":"/r","branch":"main","is_main":true,"open":true},
8024                    {"path":"/w1","branch":"main","is_main":false,"open":true},
8025                    {"path":"/w2","branch":"feature","is_main":false,"open":true},
8026                    // Detached: no branch, so nothing to resolve.
8027                    {"path":"/w3","is_main":false,"open":true}
8028                ]
8029            },
8030            {
8031                // Not on GitHub: contributes no targets at all.
8032                "main_repo":"local","root":"/l",
8033                "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
8034            }
8035        ]});
8036        let targets = pr_targets_from_snapshot(&snapshot);
8037        assert_eq!(
8038            targets,
8039            vec![
8040                PrTarget {
8041                    owner: "rust-works".into(),
8042                    name: "omni-dev".into(),
8043                    branch: "feature".into()
8044                },
8045                PrTarget {
8046                    owner: "rust-works".into(),
8047                    name: "omni-dev".into(),
8048                    branch: "main".into()
8049                },
8050            ]
8051        );
8052    }
8053
8054    #[test]
8055    fn pr_targets_from_snapshot_is_empty_without_repos() {
8056        assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
8057        assert!(pr_targets_from_snapshot(&json!({})).is_empty());
8058    }
8059
8060    #[test]
8061    fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
8062        // Defensive: a `github` object without usable owner/name strings yields no
8063        // target rather than a half-built query.
8064        for github in [
8065            json!({}),
8066            json!({"owner": "o"}),
8067            json!({"owner": 1, "name": 2}),
8068        ] {
8069            let snapshot = json!({"repos":[{
8070                "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
8071                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8072            }]});
8073            assert!(
8074                pr_targets_from_snapshot(&snapshot).is_empty(),
8075                "{snapshot:?}"
8076            );
8077        }
8078    }
8079
8080    #[test]
8081    fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
8082        // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
8083        // absent (the default-off case) or explicitly false contributes no watch,
8084        // so the poll never mentions it. Only an enabled repo is polled.
8085        for repo in [
8086            json!({
8087                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8088                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8089            }),
8090            json!({
8091                "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8092                "polling_enabled":false,
8093                "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8094            }),
8095        ] {
8096            let snapshot = json!({ "repos": [repo] });
8097            assert!(
8098                pr_targets_from_snapshot(&snapshot).is_empty(),
8099                "not-polled repo must yield no targets: {snapshot:?}"
8100            );
8101        }
8102        // Flipping the same repo to enabled makes it contribute.
8103        let enabled = json!({"repos":[{
8104            "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
8105            "polling_enabled":true,
8106            "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
8107        }]});
8108        assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
8109    }
8110
8111    #[test]
8112    fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
8113        let backoff = Duration::from_secs(600);
8114        // Never fetched: go.
8115        assert!(pr_should_fetch(false, None, backoff));
8116        // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
8117        // spend nothing.
8118        assert!(!pr_should_fetch(
8119            false,
8120            Some(Duration::from_secs(1)),
8121            backoff
8122        ));
8123        // Quiet tree, backoff elapsed: time to look again.
8124        assert!(pr_should_fetch(false, Some(backoff), backoff));
8125        assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
8126        // The load-bearing case: the watch grew (a target added, or an upstream
8127        // pushed), so fetch **now** regardless of how deep the backoff had grown.
8128        // Without this a push waits out the full ceiling on a stale badge.
8129        assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
8130        assert!(pr_should_fetch(
8131            true,
8132            Some(Duration::from_millis(1)),
8133            backoff
8134        ));
8135    }
8136
8137    #[test]
8138    fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
8139        let base = Duration::from_secs(10);
8140        let fresh = Some(Duration::ZERO);
8141        let stale = Some(PENDING_FAST_WINDOW);
8142        // Pending and fresh (within the fast window): hold `base`, however long we
8143        // had backed off for.
8144        assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
8145        assert_eq!(
8146            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
8147            base
8148        );
8149        // Pending but past the fast window (a long CI run): escalate — double up to
8150        // the pending ceiling, never to the terminal one.
8151        assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
8152        assert_eq!(
8153            next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
8154            PENDING_MAX_INTERVAL
8155        );
8156        // Pending with nothing having moved yet (`None`) is treated as past the fast
8157        // window, so a stale-from-boot pending state does not pin `base`.
8158        assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
8159        // Everything terminal: double…
8160        assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
8161        assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
8162        // …up to the terminal ceiling (above the pending one), and never overflow.
8163        assert_eq!(
8164            next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
8165            MAX_PR_POLL_INTERVAL
8166        );
8167        assert_eq!(
8168            next_pr_poll_delay(Duration::MAX, base, false, None),
8169            MAX_PR_POLL_INTERVAL
8170        );
8171    }
8172
8173    /// A watch on one branch with the given upstream tip.
8174    fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
8175        PrWatch {
8176            target: PrTarget {
8177                owner: "rust-works".into(),
8178                name: "omni-dev".into(),
8179                branch: branch.into(),
8180            },
8181            upstream_sha: upstream.map(str::to_string),
8182        }
8183    }
8184
8185    #[test]
8186    fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
8187        let a = watch("a", Some("111"));
8188        let b = watch("b", Some("222"));
8189        let ab = [a.clone(), b.clone()];
8190        let just_a = std::slice::from_ref(&a);
8191        let just_b = std::slice::from_ref(&b);
8192        // Nothing new: quiet tick.
8193        assert!(!pr_watch_grew(&ab, &ab));
8194        // Addition: a new target appeared.
8195        assert!(pr_watch_grew(just_a, &ab));
8196        // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
8197        assert!(!pr_watch_grew(&ab, just_a));
8198        // First sight (empty prev, from `None`): everything is new.
8199        assert!(pr_watch_grew(&[], just_a));
8200        // A push moves only the upstream — still "grew".
8201        let a_pushed = [watch("a", Some("999"))];
8202        assert!(pr_watch_grew(just_a, &a_pushed));
8203        // Gaining a target while losing another still fetches (the gain wins).
8204        assert!(pr_watch_grew(just_a, just_b));
8205    }
8206
8207    #[test]
8208    fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
8209        let base = Duration::from_secs(10);
8210        let over = RateLimitSnapshot {
8211            graphql: Some(rl_resource(90)),
8212            core: Some(rl_resource(3)),
8213            search: None,
8214        };
8215        let under = RateLimitSnapshot {
8216            graphql: Some(rl_resource(50)),
8217            core: Some(rl_resource(3)),
8218            search: None,
8219        };
8220        // No reading yet: unchanged.
8221        assert_eq!(budget_throttled_delay(base, None), base);
8222        // Under the warn threshold: unchanged.
8223        assert_eq!(budget_throttled_delay(base, Some(&under)), base);
8224        // Over: raised to at least the throttle floor…
8225        assert_eq!(
8226            budget_throttled_delay(base, Some(&over)),
8227            BUDGET_THROTTLE_INTERVAL
8228        );
8229        // …but a delay already above the floor is left alone (never shortened).
8230        let long = BUDGET_THROTTLE_INTERVAL * 2;
8231        assert_eq!(budget_throttled_delay(long, Some(&over)), long);
8232    }
8233
8234    #[test]
8235    fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
8236        // The persisted cache must survive a JSON round trip with `head_oid` intact
8237        // — it is the staleness key the tree wire drops, and losing it would render
8238        // every restored badge stale (#1389, fix 4).
8239        let target = PrTarget {
8240            owner: "rust-works".into(),
8241            name: "omni-dev".into(),
8242            branch: "main".into(),
8243        };
8244        let badge = PrResolution::Pr(PrBadge {
8245            number: 1337,
8246            is_draft: true,
8247            checks: PrCheckState::Pending,
8248            url: "http://x/1337".into(),
8249            head_oid: "deadbeef".into(),
8250        });
8251        let watched = vec![watch("main", Some("abc"))];
8252        let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
8253            .unwrap()
8254            .with_timezone(&Utc);
8255        let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
8256
8257        let json = serde_json::to_vec(&prefs).unwrap();
8258        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
8259        assert_eq!(back, prefs);
8260        assert_eq!(back.polled_at, Some(polled_at));
8261        assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
8262        // The restored resolution equals the original — head_oid and all.
8263        assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
8264    }
8265
8266    #[test]
8267    fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
8268        // The explicit negative must survive persistence too: restoring `NoPr`
8269        // as "absent" would lose the #1370 distinction across a restart and
8270        // re-ask GitHub for branches already known to have no PR.
8271        let target = PrTarget {
8272            owner: "rust-works".into(),
8273            name: "omni-dev".into(),
8274            branch: "feature".into(),
8275        };
8276        let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
8277        let json = serde_json::to_vec(&prefs).unwrap();
8278        let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
8279        assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
8280        assert_eq!(
8281            back.entries[0].resolution.clone().into_resolution(),
8282            PrResolution::NoPr
8283        );
8284    }
8285
8286    #[test]
8287    fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
8288        // A file with verdicts but no `polled_at` (an older shape, or a
8289        // hand-edited one) still renders badges, but must not arm the warm
8290        // start: without a poll time there is nothing to age the verdicts
8291        // against, so the poller re-polls immediately (#1389, fix 4).
8292        let dir = tempfile::tempdir().unwrap();
8293        let path = dir.path().join("pr-cache.json");
8294        let target = PrTarget {
8295            owner: "rust-works".into(),
8296            name: "omni-dev".into(),
8297            branch: "main".into(),
8298        };
8299        let mut prefs = pr_cache_prefs_from(
8300            vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
8301            &[watch("main", None)],
8302            Utc::now(),
8303        );
8304        prefs.polled_at = None;
8305        write_pr_cache(&path, &prefs).unwrap();
8306
8307        let svc = WorktreesService::new();
8308        svc.load_pr_cache(path);
8309        assert!(
8310            svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
8311            "the badge itself must still restore"
8312        );
8313        assert!(
8314            svc.pr_warm_start
8315                .lock()
8316                .unwrap_or_else(PoisonError::into_inner)
8317                .is_none(),
8318            "no poll time means a cold start, not a trusted warm one"
8319        );
8320    }
8321
8322    /// Installs a thread-local WARN-level subscriber for the duration of a
8323    /// test, so degraded-path `tracing::warn!` sites actually format their
8324    /// fields instead of short-circuiting on "nobody is listening".
8325    fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
8326        tracing::subscriber::set_default(
8327            tracing_subscriber::fmt()
8328                .with_max_level(tracing::Level::WARN)
8329                .with_writer(std::io::sink)
8330                .finish(),
8331        )
8332    }
8333
8334    #[test]
8335    fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
8336        // The best-effort contract: a mangled cache is logged and treated as
8337        // empty — never a panic — and the path is stored regardless, so the
8338        // next successful poll rewrites a clean file (#1389, fix 4).
8339        let _trace = warn_subscriber();
8340        let dir = tempfile::tempdir().unwrap();
8341        let corrupt = dir.path().join("pr-cache.json");
8342        std::fs::write(&corrupt, b"not json").unwrap();
8343        let svc = WorktreesService::new();
8344        svc.load_pr_cache(corrupt.clone());
8345        assert!(svc.pr_cache.entries().is_empty());
8346        assert_eq!(
8347            svc.pr_cache_path
8348                .lock()
8349                .unwrap_or_else(PoisonError::into_inner)
8350                .as_deref(),
8351            Some(corrupt.as_path()),
8352            "the path must be stored even when the load fails, so persistence recovers"
8353        );
8354
8355        // A directory: `read` fails with a non-NotFound error (the distinct
8356        // "could not read" arm), with the same treated-as-empty outcome.
8357        let svc = WorktreesService::new();
8358        svc.load_pr_cache(dir.path().to_path_buf());
8359        assert!(svc.pr_cache.entries().is_empty());
8360    }
8361
8362    #[test]
8363    fn persist_pr_cache_swallows_a_write_failure() {
8364        // Best-effort: an unwritable path is logged at WARN and swallowed — the
8365        // in-memory cache stays authoritative, and losing the warm start only
8366        // costs one extra poll after the next restart.
8367        let _trace = warn_subscriber();
8368        let dir = tempfile::tempdir().unwrap();
8369        let blocker = dir.path().join("blocker");
8370        std::fs::write(&blocker, b"").unwrap();
8371        // The parent is a regular file, so creating the runtime dir must fail.
8372        let path = blocker.join("pr-cache.json");
8373        persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
8374        assert!(!path.exists());
8375        // The root has no parent at all — nothing to create, and the write
8376        // itself fails (it is a directory); still swallowed.
8377        persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
8378    }
8379
8380    #[tokio::test]
8381    async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
8382        let dir = tempfile::tempdir().unwrap();
8383        let repo = github_repo(dir.path());
8384        let head = repo.head().unwrap().target().unwrap().to_string();
8385        let svc = WorktreesService::new();
8386        svc.handle(
8387            "register",
8388            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8389        )
8390        .await
8391        .unwrap();
8392        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8393        // act on it, as if the user had toggled it on.
8394        svc.registry.set_polling("rust-works", "omni-dev", true);
8395
8396        // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
8397        // and so is the negative, so "not resolved" stays distinguishable (#1370).
8398        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8399        assert!(wt.get("pr").is_none(), "{wt:?}");
8400        assert!(wt.get("pr_none").is_none(), "{wt:?}");
8401
8402        // Seed the cache the poller writes, then re-read the tree.
8403        let mut badges = HashMap::new();
8404        badges.insert(
8405            PrTarget {
8406                owner: "rust-works".into(),
8407                name: "omni-dev".into(),
8408                branch: "main".into(),
8409            },
8410            pr(pending_badge(1337, &head)),
8411        );
8412        assert!(svc.pr_cache.replace(badges));
8413
8414        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8415        assert_eq!(wt["pr"]["number"], json!(1337));
8416        assert_eq!(wt["pr"]["checks"], json!("pending"));
8417        // camelCase on the wire, or the extension silently loses the draft marker.
8418        assert_eq!(wt["pr"]["isDraft"], json!(false));
8419        // A badge and the negative are mutually exclusive.
8420        assert!(wt.get("pr_none").is_none(), "{wt:?}");
8421    }
8422
8423    #[tokio::test]
8424    async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
8425        // A detached HEAD has a commit but no branch, and a badge is keyed by
8426        // branch — so there is nothing to match. It must fall through silently
8427        // rather than borrow a badge from whatever branch happens to be cached, and
8428        // rather than sink the tree.
8429        let dir = tempfile::tempdir().unwrap();
8430        let repo = github_repo(dir.path());
8431        let head = repo.head().unwrap().target().unwrap();
8432        repo.set_head_detached(head).unwrap();
8433
8434        let svc = WorktreesService::new();
8435        svc.handle(
8436            "register",
8437            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8438        )
8439        .await
8440        .unwrap();
8441        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8442        // act on it, as if the user had toggled it on.
8443        svc.registry.set_polling("rust-works", "omni-dev", true);
8444        // A badge *is* cached for `main` — the branch this worktree was on before
8445        // detaching. It must not leak onto the now-branchless row.
8446        let mut badges = HashMap::new();
8447        badges.insert(
8448            PrTarget {
8449                owner: "rust-works".into(),
8450                name: "omni-dev".into(),
8451                branch: "main".into(),
8452            },
8453            pr(pending_badge(1, &head.to_string())),
8454        );
8455        svc.pr_cache.replace(badges);
8456
8457        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8458        assert!(wt.get("branch").is_none(), "{wt:?}");
8459        // The SHA still shows — detached means no branch, not no commit.
8460        assert_eq!(
8461            wt.get("head_sha").and_then(Value::as_str),
8462            Some(head.to_string().as_str())
8463        );
8464        assert!(wt.get("pr").is_none(), "{wt:?}");
8465        // No branch means nothing was checked either: no negative on the row.
8466        assert!(wt.get("pr_none").is_none(), "{wt:?}");
8467    }
8468
8469    #[tokio::test]
8470    async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
8471        let dir = tempfile::tempdir().unwrap();
8472        github_repo(dir.path());
8473        let svc = WorktreesService::new();
8474        svc.handle(
8475            "register",
8476            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8477        )
8478        .await
8479        .unwrap();
8480        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8481        // act on it, as if the user had toggled it on.
8482        svc.registry.set_polling("rust-works", "omni-dev", true);
8483        // A badge for a different branch must not leak onto `main`.
8484        let mut badges = HashMap::new();
8485        badges.insert(
8486            PrTarget {
8487                owner: "rust-works".into(),
8488                name: "omni-dev".into(),
8489                branch: "other".into(),
8490            },
8491            pr(pending_badge(1, "irrelevant")),
8492        );
8493        svc.pr_cache.replace(badges);
8494        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8495        assert!(wt.get("pr").is_none(), "{wt:?}");
8496        // An unmatched branch is unresolved, not negative.
8497        assert!(wt.get("pr_none").is_none(), "{wt:?}");
8498    }
8499
8500    #[tokio::test]
8501    async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
8502        // The #1370 fix on the wire: a branch the poller checked and found PR-less
8503        // carries `pr_none: true` — never a sentinel `pr` object — so a client can
8504        // tell "checked, none" from "not resolved" and keep its fallback quiet.
8505        let dir = tempfile::tempdir().unwrap();
8506        github_repo(dir.path());
8507        let svc = WorktreesService::new();
8508        svc.handle(
8509            "register",
8510            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8511        )
8512        .await
8513        .unwrap();
8514        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8515        // act on it, as if the user had toggled it on.
8516        svc.registry.set_polling("rust-works", "omni-dev", true);
8517
8518        let mut resolutions = HashMap::new();
8519        resolutions.insert(
8520            PrTarget {
8521                owner: "rust-works".into(),
8522                name: "omni-dev".into(),
8523                branch: "main".into(),
8524            },
8525            PrResolution::NoPr,
8526        );
8527        assert!(svc.pr_cache.replace(resolutions));
8528
8529        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8530        assert_eq!(wt["pr_none"], json!(true));
8531        // Mutually exclusive with a badge.
8532        assert!(wt.get("pr").is_none(), "{wt:?}");
8533    }
8534
8535    #[tokio::test]
8536    async fn a_commit_does_not_drop_a_negative_resolution() {
8537        // A negative has no commit to be stale against. Dropping it when HEAD
8538        // moves would flip the row back to "unresolved" on every local commit —
8539        // re-arming every client's `gh` fallback, the very cost #1370 removes. The
8540        // poller's `moved` trigger re-checks the branch promptly instead.
8541        let dir = tempfile::tempdir().unwrap();
8542        let repo = github_repo(dir.path());
8543        let first = repo.head().unwrap().target().unwrap();
8544
8545        let svc = WorktreesService::new();
8546        svc.handle(
8547            "register",
8548            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8549        )
8550        .await
8551        .unwrap();
8552        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8553        // act on it, as if the user had toggled it on.
8554        svc.registry.set_polling("rust-works", "omni-dev", true);
8555
8556        let mut resolutions = HashMap::new();
8557        resolutions.insert(
8558            PrTarget {
8559                owner: "rust-works".into(),
8560                name: "omni-dev".into(),
8561                branch: "main".into(),
8562            },
8563            PrResolution::NoPr,
8564        );
8565        svc.pr_cache.replace(resolutions);
8566
8567        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8568        assert_eq!(wt["pr_none"], json!(true));
8569
8570        // Commit — as a push would leave things, with the cache untouched.
8571        let head = repo.find_commit(first).unwrap();
8572        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8573
8574        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8575        assert_eq!(
8576            wt["pr_none"],
8577            json!(true),
8578            "a local commit must not drop the negative"
8579        );
8580    }
8581
8582    #[tokio::test]
8583    async fn pr_poller_asks_nothing_while_no_window_is_registered() {
8584        // The idle case — the daemon runs all day with no editor open. Point it at a
8585        // stub that fails loudly if ever spawned: a poll here would both waste
8586        // GitHub budget and, on a real `gh`, wake the radio for nothing.
8587        let bin_dir = tempfile::tempdir().unwrap();
8588        let marker = bin_dir.path().join("spawned");
8589        let fake = bin_dir.path().join("fake-gh");
8590        std::fs::write(
8591            &fake,
8592            format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
8593        )
8594        .unwrap();
8595        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
8596        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
8597        std::fs::set_permissions(&fake, perms).unwrap();
8598
8599        let svc = WorktreesService::new();
8600        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
8601        tokio::time::sleep(Duration::from_millis(200)).await;
8602        svc.shutdown().await;
8603        assert!(
8604            !marker.exists(),
8605            "the poller must not spawn gh with no windows registered"
8606        );
8607    }
8608
8609    #[tokio::test]
8610    async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
8611        // Badges are decoration: an unauthenticated or broken `gh` must never sink
8612        // the tree, and one bad poll must not blank rows that were fine a second ago.
8613        let dir = tempfile::tempdir().unwrap();
8614        let repo = github_repo(dir.path());
8615        let head = repo.head().unwrap().target().unwrap().to_string();
8616        let bin_dir = tempfile::tempdir().unwrap();
8617        let fake = bin_dir.path().join("fake-gh");
8618        // Exits non-zero, exactly as `gh` does without `gh auth login`.
8619        std::fs::write(
8620            &fake,
8621            "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
8622        )
8623        .unwrap();
8624        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
8625        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
8626        std::fs::set_permissions(&fake, perms).unwrap();
8627
8628        let svc = WorktreesService::new();
8629        svc.handle(
8630            "register",
8631            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8632        )
8633        .await
8634        .unwrap();
8635        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8636        // act on it, as if the user had toggled it on.
8637        svc.registry.set_polling("rust-works", "omni-dev", true);
8638        // Seed a badge as though an earlier poll had succeeded.
8639        let mut seeded = HashMap::new();
8640        seeded.insert(
8641            PrTarget {
8642                owner: "rust-works".into(),
8643                name: "omni-dev".into(),
8644                branch: "main".into(),
8645            },
8646            pr(pending_badge(7, &head)),
8647        );
8648        svc.pr_cache.replace(seeded);
8649
8650        svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
8651        tokio::time::sleep(Duration::from_millis(200)).await;
8652
8653        // The tree still serves, and the seeded badge survived the failing polls —
8654        // which also minted no false "no PR" negatives (#1370).
8655        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8656        assert_eq!(wt["pr"]["number"], json!(7));
8657        assert!(wt.get("pr_none").is_none(), "{wt:?}");
8658        svc.shutdown().await;
8659    }
8660
8661    #[tokio::test]
8662    // The shim guard is deliberately held across the awaits below: it must span
8663    // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
8664    // is against another test writing while this one forks. Safe here — only test
8665    // threads take it, never a task inside the runtime, so it cannot deadlock.
8666    // Scoped per-test rather than on the module, which would also silence the
8667    // registry lock's "never held across .await" invariant.
8668    #[allow(clippy::await_holding_lock)]
8669    async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
8670        // The normal startup order: the daemon starts at login, *before* any
8671        // editor. It therefore sees an empty tree and backs off to the 30-minute
8672        // ceiling — so unless a register wakes it, the first badge of the session
8673        // arrives up to half an hour after the window does, which reads as the
8674        // feature being broken rather than slow.
8675        let dir = tempfile::tempdir().unwrap();
8676        github_repo(dir.path());
8677        let bin_dir = tempfile::tempdir().unwrap();
8678        let (fake, _shim) = fake_gh(
8679            bin_dir.path(),
8680            r#"{"data":{"r0":{"b0":{
8681                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
8682                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
8683                ]}}},
8684                "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
8685            }}}}"#,
8686        );
8687
8688        let svc = WorktreesService::new();
8689        // Poller first, with nothing registered — it backs off on the empty tree.
8690        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
8691        tokio::time::sleep(Duration::from_millis(150)).await;
8692
8693        // Now an editor opens.
8694        svc.handle(
8695            "register",
8696            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8697        )
8698        .await
8699        .unwrap();
8700        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8701        // act on it, as if the user had toggled it on.
8702        svc.registry.set_polling("rust-works", "omni-dev", true);
8703
8704        // The badge must follow promptly — the register wakes the loop out of its
8705        // backoff. The deadline is orders of magnitude below the ceiling, so this
8706        // fails on the bug rather than merely being slow.
8707        let badge = tokio::time::timeout(Duration::from_secs(30), async {
8708            loop {
8709                if let Some(PrResolution::Pr(badge)) =
8710                    svc.pr_cache.get("rust-works", "omni-dev", "main")
8711                {
8712                    return badge;
8713                }
8714                tokio::time::sleep(Duration::from_millis(25)).await;
8715            }
8716        })
8717        .await
8718        .expect("a window opening must wake the poller out of its idle backoff");
8719        assert_eq!(badge.number, 99);
8720        svc.shutdown().await;
8721    }
8722
8723    #[tokio::test]
8724    async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
8725        // The acceptance criterion: "pushing a new commit invalidates the badge
8726        // rather than leaving the previous head's verdict standing."
8727        //
8728        // The cache still holds the verdict for the *old* commit, and the poller may
8729        // have backed off for up to half an hour. So the fold — which runs on every
8730        // snapshot — has to notice on its own, with no network call.
8731        let dir = tempfile::tempdir().unwrap();
8732        let repo = github_repo(dir.path());
8733        let first = repo.head().unwrap().target().unwrap();
8734
8735        let svc = WorktreesService::new();
8736        svc.handle(
8737            "register",
8738            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
8739        )
8740        .await
8741        .unwrap();
8742        // Polling defaults off (#1376): enable it for this repo so the poller/fold
8743        // act on it, as if the user had toggled it on.
8744        svc.registry.set_polling("rust-works", "omni-dev", true);
8745
8746        // A green verdict, correctly describing the commit currently checked out.
8747        let mut badges = HashMap::new();
8748        badges.insert(
8749            PrTarget {
8750                owner: "rust-works".into(),
8751                name: "omni-dev".into(),
8752                branch: "main".into(),
8753            },
8754            pr(PrBadge {
8755                number: 1337,
8756                is_draft: false,
8757                checks: PrCheckState::Success,
8758                url: "u".into(),
8759                head_oid: first.to_string(),
8760            }),
8761        );
8762        svc.pr_cache.replace(badges);
8763
8764        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8765        assert_eq!(
8766            wt["pr"]["checks"],
8767            json!("success"),
8768            "green for its own commit"
8769        );
8770
8771        // Now commit — as a push would leave things, with the cache untouched.
8772        let head = repo.find_commit(first).unwrap();
8773        empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
8774
8775        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
8776        assert_eq!(
8777            wt["pr"]["checks"],
8778            json!("pending"),
8779            "the previous commit's ✓ must not stand after a new commit"
8780        );
8781        // The PR itself is still shown — it is the *verdict* that is unknown, not
8782        // the PR.
8783        assert_eq!(wt["pr"]["number"], json!(1337));
8784    }
8785
8786    #[test]
8787    fn is_stale_for_compares_the_commit_the_verdict_describes() {
8788        let badge = pending_badge(1, "aaa");
8789        assert!(!badge.is_stale_for(Some("aaa")));
8790        assert!(badge.is_stale_for(Some("bbb")));
8791        // No local HEAD (unborn): nothing to compare against, so not stale.
8792        assert!(!badge.is_stale_for(None));
8793    }
8794
8795    #[test]
8796    fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
8797        // #1389, fix 3. A local commit moves only the head — GitHub has not seen
8798        // it — so asking would return exactly the cached verdict, and the badge
8799        // stays correctly stale via `is_stale_for` with no network. So a snapshot
8800        // that differs *only* in `head_sha` must compare **equal** as a watch.
8801        let snap = |sha: &str| {
8802            json!({"repos":[{
8803                "main_repo":"omni-dev",
8804                "github":{"owner":"rust-works","name":"omni-dev"},
8805                "root":"/r",
8806                "polling_enabled":true,
8807                "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
8808            }]})
8809        };
8810        let before = pr_watch_from_snapshot(&snap("aaa"));
8811        let after = pr_watch_from_snapshot(&snap("bbb"));
8812        assert_eq!(before.len(), 1);
8813        assert_eq!(before[0].target, after[0].target);
8814        // The head moved, but the watch did not — no fetch trigger, no `gh` call.
8815        assert_eq!(before, after);
8816        assert!(!pr_watch_grew(&before, &after));
8817    }
8818
8819    #[test]
8820    fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
8821        // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
8822        // it moves no local head — so an upstream move alone must register as "go
8823        // and ask now", or the badge sits at `●` until the backoff elapses.
8824        let snap = |upstream: &str| {
8825            json!({"repos":[{
8826                "main_repo":"omni-dev",
8827                "github":{"owner":"rust-works","name":"omni-dev"},
8828                "root":"/r",
8829                "polling_enabled":true,
8830                "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
8831                              "upstream_sha":upstream,"is_main":true,"open":true}]
8832            }]})
8833        };
8834        let before = pr_watch_from_snapshot(&snap("aaa"));
8835        let after = pr_watch_from_snapshot(&snap("bbb"));
8836        // Same target, only the upstream moved — a genuine "grew" signal.
8837        assert_eq!(before.len(), 1);
8838        assert_eq!(before[0].target, after[0].target);
8839        assert_ne!(before, after);
8840        assert!(pr_watch_grew(&before, &after));
8841        // A quiet tick still asks nothing.
8842        assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
8843        assert!(!pr_watch_grew(
8844            &before,
8845            &pr_watch_from_snapshot(&snap("aaa"))
8846        ));
8847    }
8848
8849    #[test]
8850    fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
8851        // An older daemon — or any branch tracking nothing — simply sends no
8852        // `upstream_sha`, which reads as `None` rather than failing the poll.
8853        let snap = json!({"repos":[{
8854            "main_repo":"omni-dev",
8855            "github":{"owner":"rust-works","name":"omni-dev"},
8856            "root":"/r",
8857            "polling_enabled":true,
8858            "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
8859        }]});
8860        let watch = pr_watch_from_snapshot(&snap);
8861        assert_eq!(watch.len(), 1);
8862        assert_eq!(watch[0].upstream_sha, None);
8863    }
8864
8865    #[test]
8866    fn start_pr_poller_is_a_noop_outside_a_runtime() {
8867        let svc = WorktreesService::new();
8868        svc.start_pr_poller();
8869        assert!(svc
8870            .poller
8871            .lock()
8872            .unwrap_or_else(PoisonError::into_inner)
8873            .is_none());
8874    }
8875
8876    #[tokio::test]
8877    async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
8878        let svc = WorktreesService::new();
8879        svc.start_pr_poller_with(
8880            Duration::from_millis(50),
8881            Duration::from_millis(10),
8882            PathBuf::from("/bin/true"),
8883        );
8884        let token = svc
8885            .poller
8886            .lock()
8887            .unwrap_or_else(PoisonError::into_inner)
8888            .as_ref()
8889            .map(|t| t.token.clone())
8890            .expect("poller started");
8891
8892        // Cancel the live task, then start again: if `start` spawned a replacement
8893        // it would orphan this one, so the token staying cancelled proves it did not.
8894        token.cancel();
8895        svc.start_pr_poller_with(
8896            Duration::from_millis(50),
8897            Duration::from_millis(10),
8898            PathBuf::from("/bin/true"),
8899        );
8900        assert!(svc
8901            .poller
8902            .lock()
8903            .unwrap_or_else(PoisonError::into_inner)
8904            .as_ref()
8905            .is_some_and(|t| t.token.is_cancelled()));
8906
8907        svc.shutdown().await;
8908        assert!(svc
8909            .poller
8910            .lock()
8911            .unwrap_or_else(PoisonError::into_inner)
8912            .is_none());
8913    }
8914
8915    // --- Rate-limit monitor (#1375) ---
8916
8917    /// A resource at `used`% of a 100-request budget.
8918    fn rl_resource(used: u64) -> RateLimitResource {
8919        RateLimitResource {
8920            used,
8921            limit: 100,
8922            remaining: 100 - used,
8923            percent: used as f64,
8924            reset: 0,
8925        }
8926    }
8927
8928    #[test]
8929    fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
8930        let snap = |graphql: u64, core: u64| RateLimitSnapshot {
8931            graphql: Some(rl_resource(graphql)),
8932            core: Some(rl_resource(core)),
8933            search: None,
8934        };
8935        // First poll already over threshold → warn.
8936        assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
8937        // First poll below → no warn.
8938        assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
8939        // Crossing upward → warn.
8940        assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
8941        // Staying over → no repeat warn.
8942        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
8943        // Recovering below → no warn.
8944        assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
8945        // A *different* resource crossing while the first recovers is still caught.
8946        assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
8947    }
8948
8949    #[test]
8950    fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
8951        let svc = WorktreesService::new();
8952        svc.start_rate_limit_poller();
8953        assert!(svc
8954            .rate_limit_poller
8955            .lock()
8956            .unwrap_or_else(PoisonError::into_inner)
8957            .is_none());
8958    }
8959
8960    #[tokio::test]
8961    async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
8962        let svc = WorktreesService::new();
8963        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8964        let token = svc
8965            .rate_limit_poller
8966            .lock()
8967            .unwrap_or_else(PoisonError::into_inner)
8968            .as_ref()
8969            .map(|t| t.token.clone())
8970            .expect("poller started");
8971
8972        // Cancel the live task, then start again: a second start must not spawn a
8973        // replacement (which would orphan this one), so the token stays cancelled.
8974        token.cancel();
8975        svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
8976        assert!(svc
8977            .rate_limit_poller
8978            .lock()
8979            .unwrap_or_else(PoisonError::into_inner)
8980            .as_ref()
8981            .is_some_and(|t| t.token.is_cancelled()));
8982
8983        svc.shutdown().await;
8984        assert!(svc
8985            .rate_limit_poller
8986            .lock()
8987            .unwrap_or_else(PoisonError::into_inner)
8988            .is_none());
8989    }
8990
8991    #[tokio::test]
8992    // Holds the shim guard across awaits; see the note above.
8993    #[allow(clippy::await_holding_lock)]
8994    async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
8995        let bin_dir = tempfile::tempdir().unwrap();
8996        let (fake, _shim) = fake_gh(
8997            bin_dir.path(),
8998            r#"{"resources":{
8999                "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
9000                "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
9001            }}"#,
9002        );
9003        let svc = WorktreesService::new();
9004        // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
9005        // is being watched — a lease makes it active without needing a window/folder.
9006        svc.registry.set_polling("rust-works", "omni-dev", true);
9007        svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
9008
9009        // Each poll spawns a real subprocess; wait on a generous deadline so a
9010        // loaded machine fails honestly rather than flaking.
9011        let snap = tokio::time::timeout(Duration::from_secs(30), async {
9012            loop {
9013                if let Some(snap) = svc.rate_limit_cache.get() {
9014                    return snap;
9015                }
9016                tokio::time::sleep(Duration::from_millis(25)).await;
9017            }
9018        })
9019        .await
9020        .expect("poller should populate the cache through the fake gh");
9021        assert_eq!(snap.graphql.unwrap().used, 4100);
9022        assert_eq!(snap.core.unwrap().used, 27);
9023
9024        // The reading reaches the built-in status field via the shared cache.
9025        assert!(svc.rate_limit_cache().get().is_some());
9026
9027        svc.shutdown().await;
9028        assert!(svc
9029            .rate_limit_poller
9030            .lock()
9031            .unwrap_or_else(PoisonError::into_inner)
9032            .is_none());
9033    }
9034
9035    #[tokio::test]
9036    // Holds the shim guard across awaits; see the note above.
9037    #[allow(clippy::await_holding_lock)]
9038    async fn rate_limit_poller_stays_idle_with_nothing_registered() {
9039        // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
9040        // `/rate_limit` subprocess — the counting stub records zero spawns.
9041        let bin_dir = tempfile::tempdir().unwrap();
9042        let (fake, _shim, counter) = counting_fake_gh(
9043            bin_dir.path(),
9044            r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
9045        );
9046        let svc = WorktreesService::new();
9047        svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
9048
9049        // Give the loop several ticks; with nothing registered it must never poll.
9050        tokio::time::sleep(Duration::from_millis(300)).await;
9051        assert_eq!(
9052            gh_spawn_count(&counter),
9053            0,
9054            "idle daemon must not poll (#1389, fix 8b)"
9055        );
9056        assert!(svc.rate_limit_cache.get().is_none());
9057
9058        // Once a lease is active, the next tick populates the cache.
9059        svc.registry.set_polling("rust-works", "omni-dev", true);
9060        tokio::time::timeout(Duration::from_secs(30), async {
9061            loop {
9062                if svc.rate_limit_cache.get().is_some() {
9063                    return;
9064                }
9065                tokio::time::sleep(Duration::from_millis(25)).await;
9066            }
9067        })
9068        .await
9069        .expect("an active lease should resume polling");
9070        assert!(gh_spawn_count(&counter) >= 1);
9071        svc.shutdown().await;
9072    }
9073
9074    #[tokio::test]
9075    async fn rate_limit_poller_survives_a_failing_gh() {
9076        // A missing/failing `gh` leaves the cache empty and never wedges the loop —
9077        // the degraded branch keeps the last (here, absent) reading rather than
9078        // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
9079        let svc = WorktreesService::new();
9080        svc.registry.set_polling("rust-works", "omni-dev", true);
9081        svc.start_rate_limit_poller_with(
9082            Duration::from_millis(20),
9083            PathBuf::from("/no/such/gh/xyzzy"),
9084        );
9085        // Let it fail a few times: the cache stays empty and the task stays alive.
9086        tokio::time::sleep(Duration::from_millis(150)).await;
9087        assert!(svc.rate_limit_cache.get().is_none());
9088        assert!(
9089            svc.rate_limit_poller
9090                .lock()
9091                .unwrap_or_else(PoisonError::into_inner)
9092                .is_some(),
9093            "the loop must survive a failing gh, not panic out"
9094        );
9095        svc.shutdown().await;
9096    }
9097
9098    #[test]
9099    fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
9100        let svc = WorktreesService::new();
9101        // Empty cache → no rate-limit line (the pre-#1375 shape).
9102        let items = svc.menu().items;
9103        assert!(
9104            !items
9105                .iter()
9106                .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
9107            "no github line before the first poll"
9108        );
9109
9110        // Populate the cache → the first item is the rate-limit status line.
9111        svc.rate_limit_cache.replace(RateLimitSnapshot {
9112            graphql: Some(rl_resource(82)),
9113            core: Some(rl_resource(3)),
9114            search: None,
9115        });
9116        let items = svc.menu().items;
9117        assert!(
9118            matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
9119            "expected the github line first, got {items:?}"
9120        );
9121        assert!(
9122            matches!(items.get(1), Some(MenuItem::Separator)),
9123            "expected a separator after the github line"
9124        );
9125    }
9126
9127    #[tokio::test]
9128    // Holds the shim guard across awaits; see the note above.
9129    #[allow(clippy::await_holding_lock)]
9130    async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
9131        let dir = tempfile::tempdir().unwrap();
9132        github_repo(dir.path());
9133        let bin_dir = tempfile::tempdir().unwrap();
9134        // One repo, one branch → aliases r0/b0. A still-running check so the badge
9135        // stays pending and the loop keeps its fast cadence.
9136        let (fake, _shim) = fake_gh(
9137            bin_dir.path(),
9138            r#"{"data":{"r0":{"b0":{
9139                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9140                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9141                ]}}},
9142                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9143            }}}}"#,
9144        );
9145        let svc = WorktreesService::new();
9146        svc.handle(
9147            "register",
9148            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9149        )
9150        .await
9151        .unwrap();
9152        // Polling defaults off (#1376): enable it for this repo so the poller/fold
9153        // act on it, as if the user had toggled it on.
9154        svc.registry.set_polling("rust-works", "omni-dev", true);
9155        svc.start_pr_poller_with(
9156            Duration::from_millis(50),
9157            Duration::from_millis(10),
9158            fake.clone(),
9159        );
9160
9161        // Wait on a generous wall-clock deadline: each poll spawns a real
9162        // subprocess, and under a loaded machine (a full `build.sh` runs a build
9163        // and clippy alongside) a tight budget flakes rather than fails honestly.
9164        let badge = tokio::time::timeout(Duration::from_secs(30), async {
9165            loop {
9166                if let Some(PrResolution::Pr(badge)) =
9167                    svc.pr_cache.get("rust-works", "omni-dev", "main")
9168                {
9169                    return badge;
9170                }
9171                tokio::time::sleep(Duration::from_millis(25)).await;
9172            }
9173        })
9174        .await
9175        .expect("poller should resolve a badge through the fake gh");
9176        assert_eq!(badge.number, 1337);
9177        assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
9178
9179        // The badge reaches the wire the windows actually read.
9180        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9181        assert_eq!(wt["pr"]["number"], json!(1337));
9182
9183        // And the loop is quiescent after shutdown: the generation must stop moving.
9184        svc.shutdown().await;
9185        let generation = svc.registry.change_generation();
9186        tokio::time::sleep(Duration::from_millis(120)).await;
9187        assert_eq!(
9188            svc.registry.change_generation(),
9189            generation,
9190            "no bumps after shutdown"
9191        );
9192    }
9193
9194    #[tokio::test]
9195    // Holds the shim guard across awaits; see the note above.
9196    #[allow(clippy::await_holding_lock)]
9197    async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
9198        // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
9199        // the poller folds into the shared cache — so the graphql figure stays fresh
9200        // without a standalone `/rate_limit` call.
9201        let dir = tempfile::tempdir().unwrap();
9202        github_repo(dir.path());
9203        let bin_dir = tempfile::tempdir().unwrap();
9204        let (fake, _shim) = fake_gh(
9205            bin_dir.path(),
9206            r#"{"data":{
9207                "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
9208                             "resetAt":"2026-07-21T16:00:00Z"},
9209                "r0":{"b0":{
9210                  "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9211                    {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9212                  ]}}},
9213                  "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9214                }}
9215            }}"#,
9216        );
9217        let svc = WorktreesService::new();
9218        svc.handle(
9219            "register",
9220            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9221        )
9222        .await
9223        .unwrap();
9224        svc.registry.set_polling("rust-works", "omni-dev", true);
9225        // No rate-limit poller started: the only writer of the cache is the PR poll's
9226        // folded-in budget, so a populated graphql reading proves fix 8a.
9227        svc.start_pr_poller_with(
9228            Duration::from_millis(50),
9229            Duration::from_millis(10),
9230            fake.clone(),
9231        );
9232
9233        let graphql = tokio::time::timeout(Duration::from_secs(30), async {
9234            loop {
9235                if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
9236                    return g;
9237                }
9238                tokio::time::sleep(Duration::from_millis(25)).await;
9239            }
9240        })
9241        .await
9242        .expect("the PR poll should fold its budget into the cache");
9243        assert_eq!(graphql.used, 123);
9244        assert_eq!(graphql.limit, 5000);
9245        assert_eq!(graphql.remaining, 4877);
9246        svc.shutdown().await;
9247    }
9248
9249    #[tokio::test]
9250    // Holds the shim guard across awaits; see the note above.
9251    #[allow(clippy::await_holding_lock)]
9252    async fn pr_poll_counts_every_gh_call_exactly_once() {
9253        // #1389's non-negotiable constraint (#1387): fewer calls, but every call
9254        // still counted. Compares the ground-truth number of `gh` subprocesses the
9255        // poll actually spawned against the number of successful `kind:"gh"` records
9256        // the counted `run_gh` choke point wrote — they must be equal, so a future
9257        // refactor cannot add an uncounted `gh` path (counted < spawns) without
9258        // failing here.
9259        let dir = tempfile::tempdir().unwrap();
9260        github_repo(dir.path());
9261        let bin_dir = tempfile::tempdir().unwrap();
9262        let (fake, _shim, counter) = counting_fake_gh(
9263            bin_dir.path(),
9264            r#"{"data":{"r0":{"b0":{
9265                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9266                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9267                ]}}},
9268                "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
9269            }}}}"#,
9270        );
9271        let log = bin_dir.path().join("log.jsonl");
9272        std::env::set_var("OMNI_DEV_LOG_FILE", &log);
9273
9274        let svc = WorktreesService::new();
9275        svc.handle(
9276            "register",
9277            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9278        )
9279        .await
9280        .unwrap();
9281        svc.registry.set_polling("rust-works", "omni-dev", true);
9282        svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
9283
9284        // Wait for at least one fetch to land.
9285        tokio::time::timeout(Duration::from_secs(30), async {
9286            loop {
9287                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
9288                    return;
9289                }
9290                tokio::time::sleep(Duration::from_millis(25)).await;
9291            }
9292        })
9293        .await
9294        .expect("poller should fetch through the fake gh");
9295
9296        // Stop the loop so both counts are final (no in-flight gh), then compare.
9297        svc.shutdown().await;
9298        let spawns = gh_spawn_count(&counter);
9299        let counted = counted_gh_records(&log);
9300        std::env::remove_var("OMNI_DEV_LOG_FILE");
9301        assert!(
9302            spawns >= 1,
9303            "the poll should have spent at least one gh call"
9304        );
9305        assert_eq!(
9306            counted, spawns,
9307            "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
9308        );
9309    }
9310
9311    #[tokio::test]
9312    // Holds the shim guard across awaits; see the note above.
9313    #[allow(clippy::await_holding_lock)]
9314    async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
9315        // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
9316        // its windows) that each *grow* the watch must collapse into ONE fetch on
9317        // the final set, not one per window. Two distinct repos appear back-to-back
9318        // inside the debounce window; a debounce-free loop would fetch twice.
9319        let dir_a = tempfile::tempdir().unwrap();
9320        let dir_b = tempfile::tempdir().unwrap();
9321        github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
9322        github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
9323        let bin_dir = tempfile::tempdir().unwrap();
9324        // Both terminal, so no fast pending cadence can add a second fetch.
9325        let (fake, _shim, counter) = counting_fake_gh(
9326            bin_dir.path(),
9327            r#"{"data":{
9328                "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
9329                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9330                  "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
9331                "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
9332                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9333                  "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
9334            }}"#,
9335        );
9336        let svc = WorktreesService::new();
9337        // Enable polling for both before they register, so the first snapshot after
9338        // the storm already counts them.
9339        svc.registry.set_polling("rust-works", "omni-dev", true);
9340        svc.registry.set_polling("rust-works", "other-repo", true);
9341        // `base` far larger than the test so only the storm — never the cadence —
9342        // can trigger a fetch; a generous debounce so the two registers land inside
9343        // one settle window even on a loaded machine.
9344        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
9345        // The burst: both windows register back-to-back.
9346        svc.handle(
9347            "register",
9348            json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
9349        )
9350        .await
9351        .unwrap();
9352        // A beat between the two, so the first bump has (all but certainly)
9353        // woken the poller into its settle window before the second arrives —
9354        // exercising the debounce *restart*, not just a single coalesced wake.
9355        tokio::time::sleep(Duration::from_millis(50)).await;
9356        svc.handle(
9357            "register",
9358            json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
9359        )
9360        .await
9361        .unwrap();
9362
9363        // Wait until both badges resolve — proving the single fetch covered the full
9364        // final set, not just the first window.
9365        tokio::time::timeout(Duration::from_secs(30), async {
9366            loop {
9367                let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
9368                let b = svc
9369                    .pr_cache
9370                    .get("rust-works", "other-repo", "main")
9371                    .is_some();
9372                if a && b {
9373                    return;
9374                }
9375                tokio::time::sleep(Duration::from_millis(25)).await;
9376            }
9377        })
9378        .await
9379        .expect("the debounced fetch should resolve both repos");
9380
9381        svc.shutdown().await;
9382        assert_eq!(
9383            gh_spawn_count(&counter),
9384            1,
9385            "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
9386        );
9387    }
9388
9389    #[tokio::test]
9390    // Holds the shim guard across awaits; see the note above.
9391    #[allow(clippy::await_holding_lock)]
9392    async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
9393        // The settle loop is bounded: a drip of registry bumps, each landing
9394        // inside the debounce window, must not postpone the poll forever — the
9395        // overall deadline (4× the debounce) forces the snapshot mid-storm
9396        // (#1389, fix 2).
9397        let dir = tempfile::tempdir().unwrap();
9398        github_repo(dir.path());
9399        let bin_dir = tempfile::tempdir().unwrap();
9400        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9401        let svc = WorktreesService::new();
9402        svc.registry.set_polling("rust-works", "omni-dev", true);
9403        // `base` far past the timeout below, so only the grew-trigger — released by
9404        // the deadline — can fetch; a base the drip could outlive would let the
9405        // periodic cadence satisfy the assertion on a very slow run (#1426).
9406        svc.start_pr_poller_with(Duration::from_secs(300), Duration::from_millis(50), fake);
9407        let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
9408        svc.handle("register", register.clone()).await.unwrap();
9409        // Re-register (an upsert, but still a bump) every 25ms — inside the 50ms
9410        // debounce — until the deadline-forced fetch actually lands. The observation
9411        // point is the condition itself, never a clock: sampling the counter at a
9412        // fixed instant would additionally assert that a `fork` + `exec` + `/bin/sh`
9413        // startup + file append all beat that instant, which is what made this flaky
9414        // under full-suite load (#1426). Under load the drip just runs longer. Every
9415        // iteration bumps, so the fetch is still observed *while bumps are arriving*
9416        // — a deadline-free settle loop never fetches here at all, and the timeout
9417        // *is* the assertion.
9418        let forced_mid_drip = tokio::time::timeout(Duration::from_secs(10), async {
9419            loop {
9420                tokio::time::sleep(Duration::from_millis(25)).await;
9421                svc.handle("register", register.clone()).await.unwrap();
9422                if gh_spawn_count(&counter) >= 1 {
9423                    return;
9424                }
9425            }
9426        })
9427        .await;
9428        svc.shutdown().await;
9429        forced_mid_drip.expect(
9430            "the deadline must force a fetch while the drip is still running (#1389, fix 2)",
9431        );
9432    }
9433
9434    #[tokio::test]
9435    // Holds the shim guard across awaits; see the note above.
9436    #[allow(clippy::await_holding_lock)]
9437    async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
9438        // #1389, fix 4: a daemon restart within the backoff window serves badges
9439        // from the persisted cache and spends **no** gh call, because every current
9440        // target already has a fresh verdict.
9441        let dir = tempfile::tempdir().unwrap();
9442        let repo = github_repo(dir.path());
9443        let head = repo.head().unwrap().target().unwrap().to_string();
9444        let bin_dir = tempfile::tempdir().unwrap();
9445        // If the poller wrongly fetched, this empty reply would still spawn the stub
9446        // and bump the counter — which is exactly what the assertion catches.
9447        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9448
9449        // Persist a fresh cache the way the previous daemon would have: a badge for
9450        // `main` whose verdict is about the current head (so it is not stale),
9451        // watched at `(main, no upstream)`, resolved just now.
9452        let cache_path = bin_dir.path().join("pr-cache.json");
9453        let target = PrTarget {
9454            owner: "rust-works".into(),
9455            name: "omni-dev".into(),
9456            branch: "main".into(),
9457        };
9458        let prefs = pr_cache_prefs_from(
9459            vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
9460            &[watch("main", None)],
9461            Utc::now(),
9462        );
9463        write_pr_cache(&cache_path, &prefs).unwrap();
9464
9465        let svc = WorktreesService::new();
9466        svc.load_pr_cache(cache_path);
9467        svc.registry.set_polling("rust-works", "omni-dev", true);
9468        svc.handle(
9469            "register",
9470            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9471        )
9472        .await
9473        .unwrap();
9474        // `base` far larger than the test: the only fetch that could happen is the
9475        // immediate one we expect the warm cache to skip.
9476        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
9477
9478        // The restored badge renders on the wire without a gh call.
9479        let number = tokio::time::timeout(Duration::from_secs(30), async {
9480            loop {
9481                let tree = svc.handle("tree", Value::Null).await.unwrap();
9482                if let Some(n) = repos_of(&tree)
9483                    .first()
9484                    .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
9485                {
9486                    return n;
9487                }
9488                tokio::time::sleep(Duration::from_millis(25)).await;
9489            }
9490        })
9491        .await
9492        .expect("the restored badge should render from the warm cache");
9493        assert_eq!(number, 1337);
9494
9495        // Let the poller run a while, then confirm it stayed quiet.
9496        tokio::time::sleep(Duration::from_millis(300)).await;
9497        svc.shutdown().await;
9498        assert_eq!(
9499            gh_spawn_count(&counter),
9500            0,
9501            "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
9502        );
9503    }
9504
9505    #[tokio::test]
9506    // Holds the shim guard across awaits; see the note above.
9507    #[allow(clippy::await_holding_lock)]
9508    async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
9509        // #1389, fix 4, write side (the twin of the skip test above, which reads
9510        // a hand-written file): a successful resolve persists the cache —
9511        // creating the runtime dir if needed — so the *next* daemon restart
9512        // warm-starts from it.
9513        let dir = tempfile::tempdir().unwrap();
9514        github_repo(dir.path());
9515        let bin_dir = tempfile::tempdir().unwrap();
9516        let (fake, _shim) = fake_gh(
9517            bin_dir.path(),
9518            r#"{"data":{"r0":{"b0":{
9519                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9520                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
9521                "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
9522            }}}}"#,
9523        );
9524        let svc = WorktreesService::new();
9525        // No file yet, and no parent dir either: the load takes the benign
9526        // NotFound arm, and the write must create the `0700` runtime dir.
9527        let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
9528        svc.load_pr_cache(cache_path.clone());
9529        svc.registry.set_polling("rust-works", "omni-dev", true);
9530        svc.handle(
9531            "register",
9532            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9533        )
9534        .await
9535        .unwrap();
9536        svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
9537
9538        // A partially-written or not-yet-written file simply retries: only a
9539        // fully parseable cache ends the wait.
9540        let prefs = tokio::time::timeout(Duration::from_secs(30), async {
9541            loop {
9542                if let Ok(bytes) = std::fs::read(&cache_path) {
9543                    if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
9544                        if !prefs.entries.is_empty() {
9545                            return prefs;
9546                        }
9547                    }
9548                }
9549                tokio::time::sleep(Duration::from_millis(25)).await;
9550            }
9551        })
9552        .await
9553        .expect("a successful resolve should persist the cache file");
9554        svc.shutdown().await;
9555
9556        assert_eq!(prefs.entries[0].target.branch, "main");
9557        assert!(
9558            matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
9559            "{:?}",
9560            prefs.entries[0].resolution
9561        );
9562        assert_eq!(
9563            prefs.watched,
9564            vec![PersistedWatch {
9565                target: prefs.entries[0].target.clone(),
9566                upstream_sha: None
9567            }]
9568        );
9569        assert!(
9570            prefs.polled_at.is_some(),
9571            "the poll time is what ages the next warm start"
9572        );
9573    }
9574
9575    #[tokio::test]
9576    // Holds the shim guard across awaits; see the note above.
9577    #[allow(clippy::await_holding_lock)]
9578    async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
9579        // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
9580        // to one counted `gh pr list` per repo. A generous TTL, so the second call
9581        // is served from the cache and spawns **no** second `gh` — the whole point.
9582        let bin_dir = tempfile::tempdir().unwrap();
9583        let (fake, _shim, counter) = counting_fake_gh(
9584            bin_dir.path(),
9585            r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
9586                "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
9587        );
9588        let svc = WorktreesService::new();
9589
9590        let prs = svc
9591            .open_prs_with("rust-works", "omni-dev", fake.clone())
9592            .await
9593            .expect("gh pr list should resolve");
9594        assert_eq!(prs.len(), 1);
9595        assert_eq!(prs[0]["number"], json!(42));
9596        assert_eq!(prs[0]["url"], json!("http://x/42"));
9597        assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
9598
9599        // A second window asking the same repo is served from the shared cache.
9600        let again = svc
9601            .open_prs_with("rust-works", "omni-dev", fake.clone())
9602            .await
9603            .expect("cache hit should resolve");
9604        assert_eq!(again, prs);
9605        assert_eq!(
9606            gh_spawn_count(&counter),
9607            1,
9608            "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
9609        );
9610
9611        // The op wrapper shapes the reply and validates the payload.
9612        let reply = svc
9613            .handle(
9614                "open-prs",
9615                json!({ "owner": "rust-works", "name": "omni-dev" }),
9616            )
9617            .await
9618            .expect("open-prs op should route");
9619        assert_eq!(reply["pull_requests"][0]["number"], json!(42));
9620        assert!(svc
9621            .handle("open-prs", json!({ "owner": "  ", "name": "x" }))
9622            .await
9623            .is_err());
9624    }
9625
9626    #[test]
9627    fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
9628        // The three degraded shapes a real `gh` presents — not installed, a
9629        // nonzero exit (auth/network), and output that is not the JSON array
9630        // the menu indexes into — must each be a distinct, actionable error
9631        // rather than a panic or a silently empty list (#1389, fix 7).
9632        let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
9633        assert!(
9634            err.to_string().contains("is the GitHub CLI installed"),
9635            "{err:#}"
9636        );
9637
9638        let bin_dir = tempfile::tempdir().unwrap();
9639        let _guard = shim_lock();
9640        let failing = bin_dir.path().join("fake-gh-fails");
9641        write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
9642        let err = retry_on_etxtbsy(|| open_pr_list(&failing, "rust-works/omni-dev")).unwrap_err();
9643        assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
9644        assert!(err.to_string().contains("boom"), "{err:#}");
9645
9646        let object = bin_dir.path().join("fake-gh-object");
9647        write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
9648        let err = retry_on_etxtbsy(|| open_pr_list(&object, "rust-works/omni-dev")).unwrap_err();
9649        assert!(
9650            err.to_string().contains("did not return a JSON array"),
9651            "{err:#}"
9652        );
9653    }
9654
9655    #[tokio::test]
9656    // Holds the shim guard across awaits; see the note above.
9657    #[allow(clippy::await_holding_lock)]
9658    async fn pr_poller_throttles_when_the_budget_is_over_warn() {
9659        // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
9660        // cadence and ignores even a grown watch, so no runaway can drain the shared
9661        // budget. A recent warm `last_poll` is seeded so the "first sight always
9662        // fetches" base case cannot mask the throttle — the only thing that could
9663        // fetch here is the grew-trigger, which the throttle suppresses.
9664        let dir = tempfile::tempdir().unwrap();
9665        github_repo(dir.path());
9666        let bin_dir = tempfile::tempdir().unwrap();
9667        let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
9668
9669        let svc = WorktreesService::new();
9670        // Warm start with an *empty* watch but a fresh poll time: the registered
9671        // repo then reads as a grown watch, while `last_poll` is recent enough that
9672        // only the grew-trigger — not an elapsed backoff — could drive a fetch.
9673        *svc.pr_warm_start
9674            .lock()
9675            .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
9676            watched: vec![],
9677            polled_at: Utc::now(),
9678        });
9679        // Budget over the warn threshold before the poller starts.
9680        svc.rate_limit_cache.replace(RateLimitSnapshot {
9681            graphql: Some(rl_resource(90)),
9682            core: Some(rl_resource(3)),
9683            search: None,
9684        });
9685        svc.registry.set_polling("rust-works", "omni-dev", true);
9686        svc.handle(
9687            "register",
9688            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9689        )
9690        .await
9691        .unwrap();
9692        // `base` far larger than the test, so a fetch could only come from the
9693        // grew-trigger the throttle is meant to suppress.
9694        svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
9695
9696        // Let the poller wake on the registration and run a while.
9697        tokio::time::sleep(Duration::from_millis(300)).await;
9698        svc.shutdown().await;
9699        assert_eq!(
9700            gh_spawn_count(&counter),
9701            0,
9702            "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
9703        );
9704    }
9705
9706    #[tokio::test]
9707    // Holds the shim guard across awaits; see the note above.
9708    #[allow(clippy::await_holding_lock)]
9709    async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
9710        // The diff-and-drop contract: an unchanged poll must not bump, or every
9711        // window re-renders on every tick — the cost this design exists to avoid.
9712        let dir = tempfile::tempdir().unwrap();
9713        github_repo(dir.path());
9714        let bin_dir = tempfile::tempdir().unwrap();
9715        let (fake, _shim) = fake_gh(
9716            bin_dir.path(),
9717            r#"{"data":{"r0":{"b0":{
9718                "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
9719                  {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
9720                ]}}},
9721                "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
9722            }}}}"#,
9723        );
9724        let svc = WorktreesService::new();
9725        svc.handle(
9726            "register",
9727            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9728        )
9729        .await
9730        .unwrap();
9731        // Polling defaults off (#1376): enable it for this repo so the poller/fold
9732        // act on it, as if the user had toggled it on.
9733        svc.registry.set_polling("rust-works", "omni-dev", true);
9734        svc.start_pr_poller_with(
9735            Duration::from_millis(50),
9736            Duration::from_millis(10),
9737            fake.clone(),
9738        );
9739
9740        tokio::time::timeout(Duration::from_secs(30), async {
9741            loop {
9742                if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
9743                    return;
9744                }
9745                tokio::time::sleep(Duration::from_millis(25)).await;
9746            }
9747        })
9748        .await
9749        .expect("poller should resolve a badge through the fake gh");
9750        // The fake always answers identically, so after the first resolve every
9751        // subsequent poll is a no-change and must leave the generation alone.
9752        let settled = svc.registry.change_generation();
9753        tokio::time::sleep(Duration::from_millis(150)).await;
9754        assert_eq!(
9755            svc.registry.change_generation(),
9756            settled,
9757            "an unchanged poll must not bump the change-notify"
9758        );
9759        svc.shutdown().await;
9760    }
9761
9762    #[tokio::test]
9763    // Holds the shim guard across awaits; see the note above.
9764    #[allow(clippy::await_holding_lock)]
9765    async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
9766        // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
9767        // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
9768        // as `pr_none`, and — since the answer never changes — bumps the
9769        // change-notify only for the poll that first delivered it.
9770        let dir = tempfile::tempdir().unwrap();
9771        github_repo(dir.path());
9772        let bin_dir = tempfile::tempdir().unwrap();
9773        let (fake, _shim) = fake_gh(
9774            bin_dir.path(),
9775            r#"{"data":{"r0":{"b0":{
9776                "target":{"oid":"abc","statusCheckRollup":null},
9777                "associatedPullRequests":{"nodes":[]}
9778            }}}}"#,
9779        );
9780        let svc = WorktreesService::new();
9781        svc.handle(
9782            "register",
9783            json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
9784        )
9785        .await
9786        .unwrap();
9787        // Polling defaults off (#1376): enable it for this repo so the poller/fold
9788        // act on it, as if the user had toggled it on.
9789        svc.registry.set_polling("rust-works", "omni-dev", true);
9790        svc.start_pr_poller_with(
9791            Duration::from_millis(50),
9792            Duration::from_millis(10),
9793            fake.clone(),
9794        );
9795
9796        tokio::time::timeout(Duration::from_secs(30), async {
9797            loop {
9798                if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
9799                    return;
9800                }
9801                tokio::time::sleep(Duration::from_millis(25)).await;
9802            }
9803        })
9804        .await
9805        .expect("poller should resolve the negative through the fake gh");
9806
9807        // The negative reaches the wire the windows actually read.
9808        let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
9809        assert_eq!(wt["pr_none"], json!(true));
9810        assert!(wt.get("pr").is_none(), "{wt:?}");
9811
9812        // Identical re-polls of the same negative must not bump.
9813        let settled = svc.registry.change_generation();
9814        tokio::time::sleep(Duration::from_millis(150)).await;
9815        assert_eq!(
9816            svc.registry.change_generation(),
9817            settled,
9818            "an unchanged negative must not bump the change-notify"
9819        );
9820        svc.shutdown().await;
9821    }
9822
9823    #[test]
9824    fn sync_indicator_formats_only_with_upstream() {
9825        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
9826        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
9827        assert_eq!(sync_indicator(None, None), None);
9828        // A partial pair (no real upstream) yields nothing.
9829        assert_eq!(sync_indicator(Some(1), None), None);
9830    }
9831
9832    #[tokio::test]
9833    async fn list_enriches_entries_with_git_status() {
9834        let dir = tempfile::tempdir().unwrap();
9835        let repo = init_repo(dir.path());
9836        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9837        repo.set_head("refs/heads/main").unwrap();
9838
9839        let svc = WorktreesService::new();
9840        svc.handle(
9841            "register",
9842            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9843        )
9844        .await
9845        .unwrap();
9846        let payload = svc.handle("list", Value::Null).await.unwrap();
9847        let windows = windows_of(&payload);
9848        assert_eq!(windows.len(), 1);
9849        assert_eq!(
9850            windows[0].get("branch").and_then(Value::as_str),
9851            Some("main")
9852        );
9853        // No upstream configured → the ahead/behind keys are absent, not zero.
9854        assert!(windows[0].get("ahead").is_none());
9855        assert!(windows[0].get("behind").is_none());
9856        // The main repo name is enriched onto the entry.
9857        assert_eq!(
9858            windows[0].get("main_repo").and_then(Value::as_str),
9859            dir.path().file_name().and_then(|n| n.to_str())
9860        );
9861
9862        // A non-repo folder is still listed, just without a branch or main repo.
9863        let plain = tempfile::tempdir().unwrap();
9864        svc.handle(
9865            "register",
9866            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
9867        )
9868        .await
9869        .unwrap();
9870        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
9871        let w2 = windows
9872            .iter()
9873            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
9874            .unwrap();
9875        assert!(w2.get("branch").is_none());
9876        assert!(w2.get("main_repo").is_none());
9877    }
9878
9879    #[test]
9880    fn window_label_prefers_git_branch_over_title() {
9881        let dir = tempfile::tempdir().unwrap();
9882        let repo = init_repo(dir.path());
9883        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9884        repo.set_head("refs/heads/main").unwrap();
9885        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9886        let entry = WindowEntry {
9887            key: "k".to_string(),
9888            folders: vec![dir.path().to_path_buf()],
9889            // Both the companion `repo` and `title` are overridden by the
9890            // git-derived main repo name and computed branch.
9891            repo: Some("companion-repo".to_string()),
9892            title: Some("ignored title".to_string()),
9893            pid: None,
9894            last_seen: Utc::now(),
9895            last_active: Duration::ZERO,
9896        };
9897        // Main checkout: `repo · branch`, and with no upstream there is no sync.
9898        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
9899    }
9900
9901    #[tokio::test]
9902    async fn list_includes_ahead_behind_for_tracking_branch() {
9903        let dir = tempfile::tempdir().unwrap();
9904        let _repo = diverging_repo(dir.path());
9905
9906        let svc = WorktreesService::new();
9907        svc.handle(
9908            "register",
9909            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
9910        )
9911        .await
9912        .unwrap();
9913        let payload = svc.handle("list", Value::Null).await.unwrap();
9914        let windows = windows_of(&payload);
9915        // A tracking branch serializes branch plus both divergence counts.
9916        assert_eq!(
9917            windows[0].get("branch").and_then(Value::as_str),
9918            Some("main")
9919        );
9920        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
9921        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
9922    }
9923
9924    #[test]
9925    fn window_label_includes_sync_for_tracking_branch() {
9926        let dir = tempfile::tempdir().unwrap();
9927        let _repo = diverging_repo(dir.path());
9928        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
9929        let entry = WindowEntry {
9930            key: "k".to_string(),
9931            folders: vec![dir.path().to_path_buf()],
9932            repo: Some("companion-repo".to_string()),
9933            title: None,
9934            pid: None,
9935            last_seen: Utc::now(),
9936            last_active: Duration::ZERO,
9937        };
9938        // A tracking branch appends the `(+ahead -behind)` sync indicator.
9939        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
9940    }
9941
9942    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
9943    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
9944    /// <wt_path>`.
9945    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
9946        let commit = repo.find_commit(base).unwrap();
9947        repo.branch(branch, &commit, false).unwrap();
9948        let reference = repo
9949            .find_reference(&format!("refs/heads/{branch}"))
9950            .unwrap();
9951        let mut opts = git2::WorktreeAddOptions::new();
9952        opts.reference(Some(&reference));
9953        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
9954    }
9955
9956    #[test]
9957    fn git_status_marks_linked_worktree_and_names_parent_repo() {
9958        let main_dir = tempfile::tempdir().unwrap();
9959        let repo = init_repo(main_dir.path());
9960        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9961        repo.set_head("refs/heads/main").unwrap();
9962
9963        // A linked worktree checked out on a new `feature` branch, in a
9964        // directory whose basename is deliberately *not* the repo name.
9965        let wt_parent = tempfile::tempdir().unwrap();
9966        let wt_path = wt_parent.path().join("feature-wt");
9967        add_worktree(&repo, a, &wt_path, "feature");
9968
9969        let status = git_status(&wt_path);
9970        assert!(status.is_worktree);
9971        assert_eq!(status.branch.as_deref(), Some("feature"));
9972        // The worktree names its *parent* repo, not its worktree-folder basename.
9973        assert_eq!(
9974            status.main_repo.as_deref(),
9975            main_dir.path().file_name().and_then(|n| n.to_str())
9976        );
9977
9978        // The main checkout resolves the same repo name and is not a worktree.
9979        let main_status = git_status(main_dir.path());
9980        assert!(!main_status.is_worktree);
9981        assert_eq!(main_status.main_repo, status.main_repo);
9982    }
9983
9984    #[test]
9985    fn window_label_marks_worktree_with_fork_glyph() {
9986        let main_dir = tempfile::tempdir().unwrap();
9987        let repo = init_repo(main_dir.path());
9988        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
9989        repo.set_head("refs/heads/main").unwrap();
9990        let wt_parent = tempfile::tempdir().unwrap();
9991        let wt_path = wt_parent.path().join("feature-wt");
9992        add_worktree(&repo, a, &wt_path, "feature");
9993
9994        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
9995        let entry = WindowEntry {
9996            key: "k".to_string(),
9997            folders: vec![wt_path],
9998            repo: Some("feature-wt".to_string()),
9999            title: None,
10000            pid: None,
10001            last_seen: Utc::now(),
10002            last_active: Duration::ZERO,
10003        };
10004        // A worktree line: parent repo, the fork glyph, then the branch (no
10005        // upstream here, so no sync suffix).
10006        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
10007    }
10008
10009    #[test]
10010    fn main_repo_name_derives_from_common_dir() {
10011        // Normal layout: the repo is the directory that contains `.git`.
10012        assert_eq!(
10013            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
10014            Some("omni-dev")
10015        );
10016        // A trailing slash on the common dir does not change the answer.
10017        assert_eq!(
10018            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
10019            Some("omni-dev")
10020        );
10021        // A bare repo: its own directory name, without the `.git` suffix.
10022        assert_eq!(
10023            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
10024            Some("omni-dev")
10025        );
10026        // A `.git` at the filesystem root has no parent name to use.
10027        assert_eq!(main_repo_name(Path::new("/.git")), None);
10028    }
10029
10030    // --- Repo/worktree tree (#1265) ----------------------------------------
10031
10032    /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
10033    /// temporary payload).
10034    fn repos_of(payload: &Value) -> Vec<Value> {
10035        payload
10036            .get("repos")
10037            .and_then(Value::as_array)
10038            .expect("repos array")
10039            .clone()
10040    }
10041
10042    fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
10043        Some(GithubIdentity {
10044            owner: owner.to_string(),
10045            name: name.to_string(),
10046        })
10047    }
10048
10049    #[test]
10050    fn github_identity_parses_supported_forms() {
10051        // https / http, with and without the `.git` suffix.
10052        assert_eq!(
10053            github_identity("https://github.com/rust-works/omni-dev.git"),
10054            github("rust-works", "omni-dev")
10055        );
10056        assert_eq!(
10057            github_identity("https://github.com/rust-works/omni-dev"),
10058            github("rust-works", "omni-dev")
10059        );
10060        assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
10061        // SCP-like and ssh:// / git:// forms.
10062        assert_eq!(
10063            github_identity("git@github.com:rust-works/omni-dev.git"),
10064            github("rust-works", "omni-dev")
10065        );
10066        assert_eq!(
10067            github_identity("ssh://git@github.com/o/r.git"),
10068            github("o", "r")
10069        );
10070        assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
10071        // A trailing slash and surrounding whitespace are tolerated.
10072        assert_eq!(
10073            github_identity("  https://github.com/o/r/  "),
10074            github("o", "r")
10075        );
10076    }
10077
10078    #[test]
10079    fn github_identity_rejects_non_github_and_malformed() {
10080        // Non-GitHub hosts.
10081        assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
10082        assert_eq!(github_identity("git@example.com:o/r.git"), None);
10083        // Missing or extra path segments.
10084        assert_eq!(github_identity("https://github.com/onlyowner"), None);
10085        assert_eq!(github_identity("https://github.com/o/r/extra"), None);
10086        assert_eq!(github_identity("https://github.com/"), None);
10087        // Not a URL at all.
10088        assert_eq!(github_identity("not a url"), None);
10089    }
10090
10091    #[test]
10092    fn remote_github_identity_reads_origin_then_falls_back() {
10093        let dir = tempfile::tempdir().unwrap();
10094        let repo = init_repo(dir.path());
10095        // No remotes → None.
10096        assert_eq!(remote_github_identity(&repo), None);
10097        // A non-GitHub origin is not a match.
10098        repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
10099        assert_eq!(remote_github_identity(&repo), None);
10100        // A GitHub origin resolves to its identity.
10101        repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
10102            .unwrap();
10103        assert_eq!(
10104            remote_github_identity(&repo),
10105            github("rust-works", "omni-dev")
10106        );
10107
10108        // Origin non-GitHub but another remote is GitHub: the fallback loop over
10109        // the remaining remotes finds it.
10110        repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
10111            .unwrap();
10112        repo.remote("upstream", "https://github.com/other/proj.git")
10113            .unwrap();
10114        assert_eq!(remote_github_identity(&repo), github("other", "proj"));
10115    }
10116
10117    #[tokio::test]
10118    async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
10119        let svc = WorktreesService::new();
10120        // No windows → an empty repo set (not an error), toggle at its default.
10121        assert_eq!(
10122            svc.handle("tree", Value::Null).await.unwrap(),
10123            json!({ "repos": [], "show_closed": true })
10124        );
10125        // A plain non-repo folder is skipped rather than sinking the op.
10126        let plain = tempfile::tempdir().unwrap();
10127        svc.handle(
10128            "register",
10129            json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
10130        )
10131        .await
10132        .unwrap();
10133        assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
10134    }
10135
10136    #[tokio::test]
10137    async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
10138        let main_dir = tempfile::tempdir().unwrap();
10139        let repo = init_repo(main_dir.path());
10140        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10141        repo.set_head("refs/heads/main").unwrap();
10142        // A GitHub origin so the repo carries an identity in the payload.
10143        repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
10144            .unwrap();
10145
10146        // A linked worktree on a new `feature` branch, in a directory whose
10147        // basename is deliberately not the repo name.
10148        let wt_parent = tempfile::tempdir().unwrap();
10149        let wt_path = wt_parent.path().join("feature-wt");
10150        add_worktree(&repo, a, &wt_path, "feature");
10151
10152        let svc = WorktreesService::new();
10153        // A window open on the main checkout and one on the linked worktree —
10154        // two windows, but one repo (they must dedupe).
10155        svc.handle(
10156            "register",
10157            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
10158        )
10159        .await
10160        .unwrap();
10161        svc.handle(
10162            "register",
10163            json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
10164        )
10165        .await
10166        .unwrap();
10167
10168        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
10169        assert_eq!(
10170            repos.len(),
10171            1,
10172            "two worktrees of one repo dedupe: {repos:?}"
10173        );
10174        let repo0 = &repos[0];
10175        // Repo identity is the parent-repo name (not a worktree-folder basename).
10176        assert_eq!(
10177            repo0.get("main_repo").and_then(Value::as_str),
10178            main_dir.path().file_name().and_then(|n| n.to_str())
10179        );
10180        assert_eq!(
10181            repo0.pointer("/github/owner").and_then(Value::as_str),
10182            Some("rust-works")
10183        );
10184        assert_eq!(
10185            repo0.pointer("/github/name").and_then(Value::as_str),
10186            Some("omni-dev")
10187        );
10188        assert!(repo0.get("root").and_then(Value::as_str).is_some());
10189
10190        let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
10191        assert_eq!(worktrees.len(), 2);
10192        // Main working tree first: is_main, open, with the main window's key.
10193        let main_wt = &worktrees[0];
10194        assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
10195        assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
10196        assert_eq!(
10197            main_wt.get("window_key").and_then(Value::as_str),
10198            Some("wm")
10199        );
10200        assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
10201        // Linked worktree: not main, open via the feature window.
10202        let linked = &worktrees[1];
10203        assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
10204        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
10205        assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
10206        assert_eq!(
10207            linked.get("branch").and_then(Value::as_str),
10208            Some("feature")
10209        );
10210    }
10211
10212    #[tokio::test]
10213    async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
10214        let main_dir = tempfile::tempdir().unwrap();
10215        let repo = init_repo(main_dir.path());
10216        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10217        repo.set_head("refs/heads/main").unwrap();
10218        // No remote at all → the repo carries no `github` identity.
10219        let wt_parent = tempfile::tempdir().unwrap();
10220        let wt_path = wt_parent.path().join("feature-wt");
10221        add_worktree(&repo, a, &wt_path, "feature");
10222
10223        let svc = WorktreesService::new();
10224        // Only the main checkout has a window; the linked worktree has none.
10225        svc.handle(
10226            "register",
10227            json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
10228        )
10229        .await
10230        .unwrap();
10231
10232        let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
10233        assert_eq!(repos.len(), 1);
10234        assert!(repos[0].get("github").is_none(), "no remote → no github");
10235        let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
10236        let linked = worktrees
10237            .iter()
10238            .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
10239            .expect("the linked worktree");
10240        // Enumerated even though no window has it open, and marked closed.
10241        assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
10242        assert!(linked.get("window_key").is_none());
10243    }
10244
10245    // --- Close op (#1277) --------------------------------------------------
10246
10247    /// Builds a repo whose main working tree is on `trunk` with one **clean**
10248    /// linked worktree on `feature`, returning the temp dirs (kept alive so the
10249    /// paths stay valid) and the linked worktree path.
10250    fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10251        let main_dir = tempfile::tempdir().unwrap();
10252        let repo = init_repo(main_dir.path());
10253        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10254        repo.set_head("refs/heads/trunk").unwrap();
10255        let wt_parent = tempfile::tempdir().unwrap();
10256        let wt_path = wt_parent.path().join("feature-wt");
10257        add_worktree(&repo, a, &wt_path, "feature");
10258        (main_dir, wt_parent, wt_path)
10259    }
10260
10261    /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
10262    /// repo — the shape a multi-select delete fans out over, and the only one where
10263    /// two prunes share a `.git/worktrees` to race on (#1359).
10264    fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
10265    {
10266        let main_dir = tempfile::tempdir().unwrap();
10267        let repo = init_repo(main_dir.path());
10268        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
10269        repo.set_head("refs/heads/trunk").unwrap();
10270        let wt_parent = tempfile::tempdir().unwrap();
10271        let first = wt_parent.path().join("first-wt");
10272        let second = wt_parent.path().join("second-wt");
10273        add_worktree(&repo, a, &first, "first");
10274        add_worktree(&repo, a, &second, "second");
10275        (main_dir, wt_parent, first, second)
10276    }
10277
10278    #[tokio::test]
10279    async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
10280        let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
10281        let svc = Arc::new(WorktreesService::new());
10282
10283        // The multi-select fan-out: one `close` op per target, both in flight at
10284        // once against the one repo's shared admin state. Genuinely concurrent
10285        // even on this single-threaded runtime — each op's prune is a
10286        // `spawn_blocking`, so awaiting its join yields to the other op.
10287        //
10288        // This guards the fan-out end-to-end (both ops complete, neither is
10289        // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
10290        // a race detector for the lock, because it is not one — it passes with the
10291        // guard removed, the two prunes being far too quick to collide reliably.
10292        let close = |path: PathBuf| {
10293            let svc = svc.clone();
10294            async move {
10295                svc.handle(
10296                    "close",
10297                    json!({ "path": path, "remove": true, "confirmed": true }),
10298                )
10299                .await
10300            }
10301        };
10302        let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
10303
10304        assert_eq!(a.unwrap(), json!({ "removed": true }));
10305        assert_eq!(b.unwrap(), json!({ "removed": true }));
10306        assert!(!first.exists());
10307        assert!(!second.exists());
10308        // Both *admin* entries pruned too, not merely the directories — the half
10309        // the two ops contend on.
10310        let repo = Repository::open(main_dir.path()).unwrap();
10311        assert!(repo.worktrees().unwrap().is_empty());
10312    }
10313
10314    // --- Merge-queue op (#1401) --------------------------------------------
10315
10316    /// Builds a repo on `branch` with one clean commit whose `origin/<branch>`
10317    /// upstream points at the **same** commit (nothing to push) and a github
10318    /// `origin` URL — the shape [`evaluate_local`] accepts. The empty tree means an
10319    /// empty (clean) working directory.
10320    fn pushed_github_repo(dir: &Path, url: &str, branch: &str) -> Repository {
10321        let repo = init_repo(dir);
10322        let refname = format!("refs/heads/{branch}");
10323        let head = empty_commit(&repo, Some(&refname), &[], "A");
10324        repo.reference(&format!("refs/remotes/origin/{branch}"), head, true, "o")
10325            .unwrap();
10326        repo.set_head(&refname).unwrap();
10327        let mut cfg = repo.config().unwrap();
10328        cfg.set_str("remote.origin.url", url).unwrap();
10329        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
10330            .unwrap();
10331        cfg.set_str(&format!("branch.{branch}.remote"), "origin")
10332            .unwrap();
10333        cfg.set_str(&format!("branch.{branch}.merge"), &refname)
10334            .unwrap();
10335        repo
10336    }
10337
10338    #[test]
10339    fn evaluate_local_accepts_a_clean_pushed_github_worktree() {
10340        let dir = tempfile::tempdir().unwrap();
10341        let _repo = pushed_github_repo(
10342            dir.path(),
10343            "https://github.com/rust-works/omni-dev.git",
10344            "feature",
10345        );
10346        let ok = evaluate_local(dir.path()).expect("should be locally eligible");
10347        assert_eq!(
10348            ok.target,
10349            PrTarget {
10350                owner: "rust-works".into(),
10351                name: "omni-dev".into(),
10352                branch: "feature".into(),
10353            }
10354        );
10355        assert!(!ok.head_sha.is_empty());
10356    }
10357
10358    #[test]
10359    fn evaluate_local_skips_an_unborn_head() {
10360        let dir = tempfile::tempdir().unwrap();
10361        let _repo = init_repo(dir.path()); // no commits
10362        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-commits");
10363    }
10364
10365    #[test]
10366    fn evaluate_local_skips_a_branch_with_no_upstream() {
10367        let dir = tempfile::tempdir().unwrap();
10368        let repo = init_repo(dir.path());
10369        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10370        repo.set_head("refs/heads/main").unwrap();
10371        // A github URL but no tracking config → nothing was ever pushed.
10372        repo.config()
10373            .unwrap()
10374            .set_str("remote.origin.url", "https://github.com/o/r.git")
10375            .unwrap();
10376        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-upstream");
10377    }
10378
10379    #[test]
10380    fn evaluate_local_skips_unpushed_local_commits() {
10381        let dir = tempfile::tempdir().unwrap();
10382        let repo = init_repo(dir.path());
10383        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
10384        let a_commit = repo.find_commit(a).unwrap();
10385        // origin/main stays at A; local advances to B → 1 ahead (unpushed).
10386        repo.reference("refs/remotes/origin/main", a, true, "o")
10387            .unwrap();
10388        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
10389        drop(a_commit);
10390        repo.set_head("refs/heads/main").unwrap();
10391        let mut cfg = repo.config().unwrap();
10392        cfg.set_str("remote.origin.url", "https://github.com/o/r.git")
10393            .unwrap();
10394        // The fetch refspec is what lets git2 map the branch to its tracking ref;
10395        // without it `upstream()` fails and the branch reads as having none.
10396        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
10397            .unwrap();
10398        cfg.set_str("branch.main.remote", "origin").unwrap();
10399        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
10400        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "unpushed");
10401    }
10402
10403    #[test]
10404    fn evaluate_local_skips_a_detached_head() {
10405        let dir = tempfile::tempdir().unwrap();
10406        let repo = pushed_github_repo(dir.path(), "https://github.com/o/r.git", "main");
10407        let head = repo.head().unwrap().target().unwrap();
10408        repo.set_head_detached(head).unwrap();
10409        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "detached");
10410    }
10411
10412    #[test]
10413    fn evaluate_local_skips_a_non_github_remote() {
10414        let dir = tempfile::tempdir().unwrap();
10415        let _repo = pushed_github_repo(dir.path(), "https://gitlab.com/o/r.git", "main");
10416        assert_eq!(evaluate_local(dir.path()).unwrap_err().kind, "no-github");
10417    }
10418
10419    #[test]
10420    fn evaluate_local_skips_a_path_that_is_not_a_repo() {
10421        // A path git cannot discover a repo from is refused up front (defensive:
10422        // the UI only ever sends real worktrees). A nonexistent path is used so the
10423        // result never depends on whether the temp dir sits inside a checkout.
10424        assert_eq!(
10425            evaluate_local(Path::new("/nonexistent/omni-dev-not-a-repo-xyz"))
10426                .unwrap_err()
10427                .kind,
10428            "not-a-repo"
10429        );
10430    }
10431
10432    #[test]
10433    fn log_merge_check_records_the_counts_under_an_info_subscriber() {
10434        // The audit line's `tracing` field expressions only evaluate when an INFO
10435        // subscriber is active — the sync helper makes that testable.
10436        let req = MergeQueueRequest {
10437            paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
10438            requester_key: Some("win-9".into()),
10439            check: true,
10440            confirmed: false,
10441        };
10442        let logs = capture_info(|| log_merge_check(&req, 1, 1));
10443        assert!(logs.contains("merge-queue check"), "{logs}");
10444        assert!(logs.contains("win-9"), "{logs}");
10445        assert!(logs.contains("requested=2"), "{logs}");
10446        assert!(logs.contains("eligible=1"), "{logs}");
10447    }
10448
10449    #[test]
10450    fn log_merge_enqueue_records_the_counts_under_an_info_subscriber() {
10451        // A CLI-style requester (no window key) logs the dash fallback.
10452        let req = MergeQueueRequest {
10453            paths: vec![PathBuf::from("/a")],
10454            requester_key: None,
10455            check: false,
10456            confirmed: true,
10457        };
10458        let logs = capture_info(|| log_merge_enqueue(&req, 2, 1, 0));
10459        assert!(logs.contains("merge-queue enqueue"), "{logs}");
10460        assert!(logs.contains("queued=2"), "{logs}");
10461        assert!(logs.contains("failed=1"), "{logs}");
10462    }
10463
10464    #[test]
10465    fn evaluate_local_flags_dirty_then_untracked() {
10466        // A linked worktree with a real checked-out file, so status is meaningful.
10467        let main_dir = tempfile::tempdir().unwrap();
10468        let repo = init_repo(main_dir.path());
10469        let a = commit_file(&repo, "refs/heads/main", "f.txt", b"hi", "A");
10470        repo.set_head("refs/heads/main").unwrap();
10471        let wt_parent = tempfile::tempdir().unwrap();
10472        let wt_path = wt_parent.path().join("feature-wt");
10473        add_worktree(&repo, a, &wt_path, "feature");
10474
10475        // Clean checkout → gate 1 passes (it trips a *later* gate, not dirty).
10476        let clean = evaluate_local(&wt_path).unwrap_err();
10477        assert_ne!(clean.kind, "dirty");
10478        assert_ne!(clean.kind, "untracked");
10479
10480        // Modify the tracked file → dirty (gate 1, before any network call).
10481        std::fs::write(wt_path.join("f.txt"), b"changed").unwrap();
10482        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "dirty");
10483
10484        // Restore it, add a new file → untracked.
10485        std::fs::write(wt_path.join("f.txt"), b"hi").unwrap();
10486        std::fs::write(wt_path.join("new.txt"), b"x").unwrap();
10487        assert_eq!(evaluate_local(&wt_path).unwrap_err().kind, "untracked");
10488    }
10489
10490    #[test]
10491    fn is_conflicting_blocks_only_dirty_and_conflicting() {
10492        assert!(is_conflicting(Some("CONFLICTING")));
10493        assert!(is_conflicting(Some("DIRTY")));
10494        assert!(!is_conflicting(Some("CLEAN")));
10495        assert!(!is_conflicting(Some("BLOCKED")));
10496        assert!(!is_conflicting(Some("UNKNOWN")));
10497        assert!(!is_conflicting(None));
10498    }
10499
10500    #[test]
10501    fn merge_queue_request_parses_batch_and_phase_flags() {
10502        let req: MergeQueueRequest = serde_json::from_value(json!({
10503            "paths": ["/a", "/b"], "requester_key": "w1", "confirmed": true
10504        }))
10505        .unwrap();
10506        assert_eq!(req.paths.len(), 2);
10507        assert_eq!(req.requester_key.as_deref(), Some("w1"));
10508        assert!(req.confirmed);
10509        assert!(!req.check);
10510        // Minimal payload: just paths; every other field defaults.
10511        let req: MergeQueueRequest = serde_json::from_value(json!({ "paths": [] })).unwrap();
10512        assert!(req.paths.is_empty());
10513        assert!(!req.check && !req.confirmed && req.requester_key.is_none());
10514    }
10515
10516    #[test]
10517    fn queued_pr_omits_already_queued_when_false() {
10518        let v = serde_json::to_value(QueuedPr {
10519            path: "/a".into(),
10520            number: 5,
10521            already_queued: false,
10522        })
10523        .unwrap();
10524        assert!(v.get("already_queued").is_none(), "{v}");
10525        let v = serde_json::to_value(QueuedPr {
10526            path: "/a".into(),
10527            number: 5,
10528            already_queued: true,
10529        })
10530        .unwrap();
10531        assert_eq!(v.get("already_queued").and_then(Value::as_bool), Some(true));
10532    }
10533
10534    #[tokio::test]
10535    async fn merge_queue_check_on_empty_selection_reports_nothing() {
10536        let svc = WorktreesService::new();
10537        let reply = svc
10538            .handle("merge-queue", json!({ "paths": [], "check": true }))
10539            .await
10540            .unwrap();
10541        assert_eq!(reply, json!({ "eligible": [], "skipped": [] }));
10542    }
10543
10544    #[tokio::test]
10545    async fn merge_queue_check_skips_a_locally_ineligible_worktree_without_reaching_github() {
10546        // An unborn repo is skipped by the *local* gates, so the op never shells
10547        // `gh` — the check completes with no network stub.
10548        let dir = tempfile::tempdir().unwrap();
10549        let _repo = init_repo(dir.path());
10550        let svc = WorktreesService::new();
10551        let reply = svc
10552            .handle(
10553                "merge-queue",
10554                json!({ "paths": [dir.path()], "check": true }),
10555            )
10556            .await
10557            .unwrap();
10558        let skipped = reply.get("skipped").and_then(Value::as_array).unwrap();
10559        assert_eq!(skipped.len(), 1);
10560        assert_eq!(
10561            skipped[0].get("kind").and_then(Value::as_str),
10562            Some("no-commits")
10563        );
10564        assert!(reply
10565            .get("eligible")
10566            .and_then(Value::as_array)
10567            .unwrap()
10568            .is_empty());
10569    }
10570
10571    /// A clean, pushed, github worktree on `feature` plus its HEAD sha — the shape
10572    /// that clears the local gates, so a test can drive the *network* gates by
10573    /// varying the fake `gh` reply. Returns the temp dir (kept alive) and the sha.
10574    fn ready_worktree() -> (tempfile::TempDir, String) {
10575        let dir = tempfile::tempdir().unwrap();
10576        let repo = pushed_github_repo(
10577            dir.path(),
10578            "https://github.com/rust-works/omni-dev.git",
10579            "feature",
10580        );
10581        let head = repo.head().unwrap().target().unwrap().to_string();
10582        (dir, head)
10583    }
10584
10585    /// The resolve reply a fake `gh` returns for branch alias r0/b0: a rollup with
10586    /// one check of `conclusion`, and one PR node `pr`.
10587    fn merge_resolve_reply(head: &str, conclusion: &str, pr: &str) -> String {
10588        format!(
10589            r#"{{"data":{{"r0":{{"b0":{{
10590                "target":{{"oid":"{head}","statusCheckRollup":{{"contexts":{{"nodes":[
10591                  {{"__typename":"CheckRun","status":"COMPLETED","conclusion":"{conclusion}"}}
10592                ]}}}}}},
10593                "associatedPullRequests":{{"nodes":[{pr}]}}
10594            }}}}}}}}"#
10595        )
10596    }
10597
10598    /// Runs [`evaluate_batch`] for a `ready_worktree` against a fake `gh` returning
10599    /// `reply`, retrying the subprocess on the shim `ETXTBSY` race. Returns the
10600    /// single worktree's outcome as `Ok(number)` when eligible or `Err(skip.kind)`.
10601    fn network_gate_outcome(head_dir: &Path, reply: &str) -> std::result::Result<u64, String> {
10602        let ghdir = tempfile::tempdir().unwrap();
10603        let (bin, _shim) = fake_gh(ghdir.path(), reply);
10604        let paths = vec![head_dir.to_path_buf()];
10605        let (eligible, mut skipped) = retry_on_etxtbsy(|| evaluate_batch(&bin, &paths)).unwrap();
10606        if let Some(e) = eligible.first() {
10607            return Ok(e.number);
10608        }
10609        Err(skipped.remove(0).kind)
10610    }
10611
10612    #[test]
10613    fn evaluate_batch_marks_a_ready_pr_eligible() {
10614        let (dir, head) = ready_worktree();
10615        let pr = format!(
10616            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10617        );
10618        assert_eq!(
10619            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10620            Ok(9)
10621        );
10622    }
10623
10624    #[test]
10625    fn evaluate_batch_skips_a_draft_pr() {
10626        let (dir, head) = ready_worktree();
10627        let pr = format!(
10628            r#"{{"id":"P","number":1,"isDraft":true,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10629        );
10630        assert_eq!(
10631            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10632            Err("draft".to_string())
10633        );
10634    }
10635
10636    #[test]
10637    fn evaluate_batch_skips_a_conflicting_pr() {
10638        let (dir, head) = ready_worktree();
10639        let pr = format!(
10640            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CONFLICTING","mergeQueueEntry":null}}"#
10641        );
10642        assert_eq!(
10643            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr)),
10644            Err("conflicting".to_string())
10645        );
10646    }
10647
10648    #[test]
10649    fn evaluate_batch_skips_a_pr_with_failing_checks() {
10650        let (dir, head) = ready_worktree();
10651        let pr = format!(
10652            r#"{{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10653        );
10654        assert_eq!(
10655            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "FAILURE", &pr)),
10656            Err("checks-failing".to_string())
10657        );
10658    }
10659
10660    #[test]
10661    fn evaluate_batch_skips_a_pr_whose_head_is_stale() {
10662        let (dir, head) = ready_worktree();
10663        // The remote PR head is a different commit than the local head.
10664        let pr = r#"{"id":"P","number":1,"isDraft":false,"url":"u","headRefOid":"0000000000000000000000000000000000000000","mergeStateStatus":"CLEAN","mergeQueueEntry":null}"#;
10665        assert_eq!(
10666            network_gate_outcome(dir.path(), &merge_resolve_reply(&head, "SUCCESS", pr)),
10667            Err("stale".to_string())
10668        );
10669    }
10670
10671    #[test]
10672    fn evaluate_batch_skips_a_branch_with_no_open_pr() {
10673        let (dir, head) = ready_worktree();
10674        // The ref resolves but no open PR heads it.
10675        let reply = format!(
10676            r#"{{"data":{{"r0":{{"b0":{{"target":{{"oid":"{head}","statusCheckRollup":null}},"associatedPullRequests":{{"nodes":[]}}}}}}}}}}"#
10677        );
10678        assert_eq!(
10679            network_gate_outcome(dir.path(), &reply),
10680            Err("no-pr".to_string())
10681        );
10682    }
10683
10684    #[test]
10685    fn enqueue_eligible_skips_already_queued_and_records_a_failed_enqueue() {
10686        // A bogus binary makes the real enqueue fail (Err → `failed[]`); the
10687        // already-queued PR needs no mutation and is reported queued.
10688        let eligible = vec![
10689            Eligible {
10690                path: PathBuf::from("/wt/a"),
10691                number: 1,
10692                url: "u".into(),
10693                branch: "a".into(),
10694                pr_id: "PR_A".into(),
10695                already_queued: true,
10696            },
10697            Eligible {
10698                path: PathBuf::from("/wt/b"),
10699                number: 2,
10700                url: "u".into(),
10701                branch: "b".into(),
10702                pr_id: "PR_B".into(),
10703                already_queued: false,
10704            },
10705        ];
10706        let (queued, failed) = enqueue_eligible(Path::new("/no/such/gh/xyzzy"), eligible);
10707        assert_eq!(queued.len(), 1);
10708        assert_eq!(queued[0].number, 1);
10709        assert!(queued[0].already_queued);
10710        assert_eq!(failed.len(), 1);
10711        assert_eq!(failed[0].number, 2);
10712    }
10713
10714    #[test]
10715    fn enqueue_eligible_records_a_github_rejection_as_failed() {
10716        // A fake `gh` returning a GraphQL rejection (a 200 with an `errors` body)
10717        // drives the `EnqueueOutcome::Rejected` arm — the PR lands in `failed[]`
10718        // rather than sinking the batch.
10719        let ghdir = tempfile::tempdir().unwrap();
10720        let (bin, _shim) = fake_gh(
10721            ghdir.path(),
10722            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
10723        );
10724        let eligible = vec![Eligible {
10725            path: PathBuf::from("/wt/a"),
10726            number: 7,
10727            url: "u".into(),
10728            branch: "a".into(),
10729            pr_id: "PR_A".into(),
10730            already_queued: false,
10731        }];
10732        let (queued, failed) = enqueue_eligible(&bin, eligible);
10733        assert!(queued.is_empty(), "{queued:?}");
10734        assert_eq!(failed.len(), 1);
10735        assert_eq!(failed[0].number, 7);
10736        // A rejection carries a non-empty reason (an `ETXTBSY` exec race would
10737        // instead surface as an `Err`, still landing in `failed[]`).
10738        assert!(!failed[0].error.is_empty(), "{}", failed[0].error);
10739    }
10740
10741    #[tokio::test]
10742    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
10743    async fn merge_queue_with_reports_a_ready_worktree_as_eligible() {
10744        // Phase 1 over a network-eligible worktree: the eligible list is non-empty,
10745        // exercising the `PrRef` mapping the empty-selection tests cannot.
10746        let (dir, head) = ready_worktree();
10747        let pr = format!(
10748            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10749        );
10750        let ghdir = tempfile::tempdir().unwrap();
10751        let (bin, _shim) = fake_gh(ghdir.path(), &merge_resolve_reply(&head, "SUCCESS", &pr));
10752        let svc = WorktreesService::new();
10753        let req = MergeQueueRequest {
10754            paths: vec![dir.path().to_path_buf()],
10755            requester_key: None,
10756            check: true,
10757            confirmed: false,
10758        };
10759        // `merge_queue_with` shells the freshly-written `fake-gh` shim via
10760        // `spawn_blocking` — a test-only ETXTBSY exec race the `shim_lock`
10761        // guard alone does not retry (see test_support::shim's module docs).
10762        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10763            .await
10764            .unwrap();
10765        let eligible = reply.get("eligible").and_then(Value::as_array).unwrap();
10766        assert_eq!(eligible.len(), 1);
10767        assert_eq!(eligible[0].get("number").and_then(Value::as_u64), Some(9));
10768        assert_eq!(
10769            eligible[0].get("branch").and_then(Value::as_str),
10770            Some("feature")
10771        );
10772    }
10773
10774    #[tokio::test]
10775    #[allow(clippy::await_holding_lock)] // the shim lock serializes subprocess execs
10776    async fn merge_queue_with_enqueues_a_ready_worktree_on_confirm() {
10777        // Phase 2 end-to-end: the argv-branching stub answers the resolve query and
10778        // the enqueue mutation distinctly, so the ready worktree's PR is queued.
10779        let (dir, head) = ready_worktree();
10780        let pr = format!(
10781            r#"{{"id":"PR_9","number":9,"isDraft":false,"url":"u9","headRefOid":"{head}","mergeStateStatus":"CLEAN","mergeQueueEntry":null}}"#
10782        );
10783        let resolve = merge_resolve_reply(&head, "SUCCESS", &pr);
10784        let ghdir = tempfile::tempdir().unwrap();
10785        let guard = shim_lock();
10786        let bin = ghdir.path().join("fake-gh");
10787        write_exec_script(
10788            &bin,
10789            &format!(
10790                "#!/bin/sh\ncase \"$*\" in\n  *enqueuePullRequest*) cat <<'JSON'\n{enqueue}\nJSON\n  ;;\n  *) cat <<'JSON'\n{resolve}\nJSON\n  ;;\nesac\n",
10791                enqueue =
10792                    r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
10793            ),
10794        );
10795        let svc = WorktreesService::new();
10796        let req = MergeQueueRequest {
10797            paths: vec![dir.path().to_path_buf()],
10798            requester_key: Some("w1".into()),
10799            check: false,
10800            confirmed: true,
10801        };
10802        // `merge_queue_with` shells the freshly-written `fake-gh` shim (twice,
10803        // for the resolve then the enqueue) via `spawn_blocking` — a test-only
10804        // ETXTBSY exec race the `shim_lock` guard alone does not retry (see
10805        // test_support::shim's module docs).
10806        let reply = retry_on_etxtbsy_async(|| svc.merge_queue_with(req.clone(), bin.clone()))
10807            .await
10808            .unwrap();
10809        drop(guard);
10810        let queued = reply.get("queued").and_then(Value::as_array).unwrap();
10811        assert_eq!(queued.len(), 1, "{reply}");
10812        assert_eq!(queued[0].get("number").and_then(Value::as_u64), Some(9));
10813        assert!(reply
10814            .get("failed")
10815            .and_then(Value::as_array)
10816            .unwrap()
10817            .is_empty());
10818    }
10819
10820    #[tokio::test]
10821    async fn concurrent_closes_overlap_their_heartbeat_waits() {
10822        let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
10823        let svc = Arc::new(WorktreesService::new());
10824        // Two *different* windows own the two targets — the multi-select shape.
10825        for (key, path) in [("w2", &first), ("w3", &second)] {
10826            svc.handle("register", json!({ "key": key, "folders": [path] }))
10827                .await
10828                .unwrap();
10829        }
10830
10831        let spawn_close = |path: PathBuf| {
10832            let svc = svc.clone();
10833            tokio::spawn(async move {
10834                svc.handle(
10835                    "close",
10836                    json!({
10837                        "path": path,
10838                        "remove": true,
10839                        "confirmed": true,
10840                        "requester_key": "w1",
10841                    }),
10842                )
10843                .await
10844            })
10845        };
10846        let a = spawn_close(first.clone());
10847        let b = spawn_close(second.clone());
10848
10849        // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
10850        // *both* windows are told to close while *neither* op has finished, so the
10851        // two multi-second heartbeat waits are in flight at once. Take the guard
10852        // before `await_windows_closed` instead of after and this fails — op B
10853        // would sit on the lock without ever marking w3, restoring exactly the
10854        // N-stacked-waits latency the fan-out exists to remove.
10855        for key in ["w2", "w3"] {
10856            let mut saw_close = false;
10857            for _ in 0..400 {
10858                let hb = svc
10859                    .handle("heartbeat", json!({ "key": key }))
10860                    .await
10861                    .unwrap();
10862                if hb.get("close").and_then(Value::as_bool) == Some(true) {
10863                    saw_close = true;
10864                    break;
10865                }
10866                tokio::time::sleep(Duration::from_millis(5)).await;
10867            }
10868            assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
10869        }
10870        assert!(
10871            !a.is_finished() && !b.is_finished(),
10872            "neither close can have finished: both windows are still registered"
10873        );
10874
10875        // Both windows close; both ops then remove.
10876        for key in ["w2", "w3"] {
10877            svc.handle("unregister", json!({ "key": key }))
10878                .await
10879                .unwrap();
10880        }
10881        assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
10882        assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
10883        assert!(!first.exists());
10884        assert!(!second.exists());
10885    }
10886
10887    #[tokio::test]
10888    async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
10889        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10890        let svc = WorktreesService::new();
10891        // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
10892        // main, no risks → the extension proceeds with no dialog.
10893        let report = svc
10894            .handle("close", json!({ "path": wt_path, "remove": true }))
10895            .await
10896            .unwrap();
10897        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
10898        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
10899        assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
10900        assert!(report
10901            .get("risks")
10902            .and_then(Value::as_array)
10903            .unwrap()
10904            .is_empty());
10905        // No side effects: the worktree still exists.
10906        assert!(wt_path.exists());
10907    }
10908
10909    #[tokio::test]
10910    async fn close_removes_a_clean_linked_worktree() {
10911        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
10912        let svc = WorktreesService::new();
10913        let reply = svc
10914            .handle(
10915                "close",
10916                json!({ "path": wt_path, "remove": true, "confirmed": true }),
10917            )
10918            .await
10919            .unwrap();
10920        assert_eq!(reply, json!({ "removed": true }));
10921        assert!(
10922            !wt_path.exists(),
10923            "the worktree directory should be deleted"
10924        );
10925    }
10926
10927    // --- Close-op audit logging (#1364) ------------------------------------
10928
10929    /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
10930    /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
10931    /// `with_default`, so it never disturbs a global subscriber other tests set.
10932    /// The audit-line tests drive the sync helpers directly (no runtime, no
10933    /// `spawn_blocking`), so the captured events fire on this thread where the
10934    /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
10935    /// under the parallel suite is *not* reliably captured this way.
10936    ///
10937    /// Thin alias over [`crate::test_support::capture_at`] at `INFO`.
10938    fn capture_info(f: impl FnOnce()) -> String {
10939        crate::test_support::capture_at(tracing::Level::INFO, f)
10940    }
10941
10942    // ── rebase op (#1415) ──────────────────────────────────────────────────
10943
10944    /// A `RebaseRequest` over `paths` with everything else defaulted.
10945    fn rebase_req(paths: Vec<PathBuf>) -> RebaseRequest {
10946        RebaseRequest {
10947            paths,
10948            requester_key: None,
10949            check: false,
10950            confirmed: false,
10951            keep_conflicts: false,
10952            autostash: false,
10953            onto: None,
10954        }
10955    }
10956
10957    /// A repo whose `main` has advanced one commit past a linked `feature`
10958    /// worktree, returning `(repo dir, worktree parent dir, worktree path)`.
10959    ///
10960    /// Deliberately **no remote**: the daemon tests drive the op with a *local*
10961    /// `--onto` (`main`), which the engine resolves with no fetch at all. That
10962    /// keeps them offline and fast — the fetch-once-per-repo path is the engine's
10963    /// own concern and is covered in `worktree_rebase.rs`.
10964    fn behind_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
10965        let main_dir = tempfile::tempdir().unwrap();
10966        let repo = init_repo(main_dir.path());
10967        let base = commit_file(&repo, "refs/heads/main", "f.txt", b"base\n", "base");
10968        repo.set_head("refs/heads/main").unwrap();
10969        let wt_parent = tempfile::tempdir().unwrap();
10970        let wt_path = wt_parent.path().join("feature-wt");
10971        add_worktree(&repo, base, &wt_path, "feature");
10972        // `main` moves on; `feature` stays at `base`, so it is 1 behind.
10973        commit_file(&repo, "refs/heads/main", "g.txt", b"ahead\n", "ahead");
10974        (main_dir, wt_parent, wt_path)
10975    }
10976
10977    #[tokio::test]
10978    async fn rebase_with_refuses_an_empty_selection() {
10979        // A bare `rebase` must be a usage error, never a silent mass-rebase.
10980        let svc = WorktreesService::new();
10981        let err = svc
10982            .rebase_with(rebase_req(Vec::new()), PathBuf::from("git"))
10983            .await
10984            .unwrap_err()
10985            .to_string();
10986        assert!(err.contains("at least one path"), "{err}");
10987    }
10988
10989    #[tokio::test]
10990    async fn rebase_with_phase_one_reports_without_rebasing() {
10991        let (_main, _parent, wt) = behind_worktree();
10992        let before = Repository::open(&wt).unwrap().head().unwrap().target();
10993
10994        let svc = WorktreesService::new();
10995        let reply = svc
10996            .rebase_with(
10997                RebaseRequest {
10998                    check: true,
10999                    onto: Some("main".into()),
11000                    ..rebase_req(vec![wt.clone()])
11001                },
11002                crate::git::resolve_git_binary(),
11003            )
11004            .await
11005            .unwrap();
11006
11007        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11008        assert_eq!(worktrees.len(), 1, "{reply}");
11009        assert_eq!(
11010            worktrees[0].get("status").and_then(Value::as_str),
11011            Some("would-rebase"),
11012            "{reply}"
11013        );
11014        // A local onto ref means no fetch was attempted at all.
11015        let fetches = reply.get("fetches").and_then(Value::as_array).unwrap();
11016        assert_eq!(fetches.len(), 1);
11017        assert_eq!(
11018            fetches[0].get("fetched").and_then(Value::as_bool),
11019            Some(false)
11020        );
11021        assert_eq!(
11022            Repository::open(&wt).unwrap().head().unwrap().target(),
11023            before,
11024            "phase 1 must not move the branch"
11025        );
11026    }
11027
11028    #[tokio::test]
11029    async fn rebase_with_phase_two_rebases_and_clears_the_rebasing_mark() {
11030        let (_main, _parent, wt) = behind_worktree();
11031        let svc = WorktreesService::new();
11032        let reply = svc
11033            .rebase_with(
11034                RebaseRequest {
11035                    confirmed: true,
11036                    onto: Some("main".into()),
11037                    ..rebase_req(vec![wt.clone()])
11038                },
11039                crate::git::resolve_git_binary(),
11040            )
11041            .await
11042            .unwrap();
11043
11044        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11045        assert_eq!(
11046            worktrees[0].get("status").and_then(Value::as_str),
11047            Some("rebased"),
11048            "{reply}"
11049        );
11050        // The transient cue is cleared on the way out, so no row keeps spinning.
11051        assert!(
11052            svc.registry.rebasing_paths().is_empty(),
11053            "the rebasing mark must be cleared after the execute"
11054        );
11055    }
11056
11057    #[tokio::test]
11058    async fn rebase_with_phase_two_reclassifies_rather_than_trusting_the_client() {
11059        // The re-validation that makes two-phase meaningful: a `confirmed` request
11060        // still runs the classifier, so a worktree that is dirty *now* is skipped
11061        // rather than rebased on the strength of an earlier phase-1 verdict.
11062        let (_main, _parent, wt) = behind_worktree();
11063        std::fs::write(wt.join("f.txt"), "local edit\n").unwrap();
11064
11065        let svc = WorktreesService::new();
11066        let reply = svc
11067            .rebase_with(
11068                RebaseRequest {
11069                    confirmed: true,
11070                    onto: Some("main".into()),
11071                    ..rebase_req(vec![wt.clone()])
11072                },
11073                crate::git::resolve_git_binary(),
11074            )
11075            .await
11076            .unwrap();
11077
11078        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11079        assert_eq!(
11080            worktrees[0].get("status").and_then(Value::as_str),
11081            Some("skipped"),
11082            "{reply}"
11083        );
11084        assert_eq!(
11085            worktrees[0].get("reason").and_then(Value::as_str),
11086            Some("dirty"),
11087            "{reply}"
11088        );
11089    }
11090
11091    #[tokio::test]
11092    async fn rebase_with_never_disturbs_a_worktree_already_mid_rebase() {
11093        // The hazard the plan-under-the-lock ordering exists to prevent: if another
11094        // run has left a worktree mid-rebase, this one must classify it as
11095        // `operation-in-progress` and leave it alone — never run `git rebase`
11096        // against it, which (without `keep_conflicts`) would `--abort` and destroy
11097        // the conflict resolution in progress.
11098        let (_main, _parent, wt) = behind_worktree();
11099        // Fake a rebase in progress the way git does: the state directory's
11100        // presence is what `Repository::state()` keys on.
11101        std::fs::create_dir_all(wt.join(".git")).ok();
11102        let git_dir = Repository::open(&wt).unwrap().path().to_path_buf();
11103        std::fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
11104        std::fs::write(git_dir.join("rebase-merge").join("interactive"), "").unwrap();
11105        assert_ne!(
11106            Repository::open(&wt).unwrap().state(),
11107            RepositoryState::Clean,
11108            "precondition: the worktree looks mid-rebase to git2"
11109        );
11110
11111        let svc = WorktreesService::new();
11112        let reply = svc
11113            .rebase_with(
11114                RebaseRequest {
11115                    confirmed: true,
11116                    onto: Some("main".into()),
11117                    ..rebase_req(vec![wt.clone()])
11118                },
11119                crate::git::resolve_git_binary(),
11120            )
11121            .await
11122            .unwrap();
11123
11124        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11125        assert_eq!(
11126            worktrees[0].get("reason").and_then(Value::as_str),
11127            Some("operation-in-progress"),
11128            "{reply}"
11129        );
11130        // Still mid-rebase: nothing aborted it out from under whoever owns it.
11131        assert_ne!(
11132            Repository::open(&wt).unwrap().state(),
11133            RepositoryState::Clean
11134        );
11135    }
11136
11137    #[test]
11138    fn rebase_request_maps_onto_engine_options() {
11139        let req = RebaseRequest {
11140            keep_conflicts: true,
11141            autostash: true,
11142            onto: Some("origin/release".into()),
11143            ..rebase_req(vec![PathBuf::from("/wt")])
11144        };
11145        let opts = req.options(PathBuf::from("/custom/git"));
11146        assert!(opts.keep_conflicts && opts.autostash);
11147        assert_eq!(opts.onto.as_deref(), Some("origin/release"));
11148        assert_eq!(opts.git_bin, Some(PathBuf::from("/custom/git")));
11149        // Phase 1 *is* the dry run (it calls `plan`, never `execute`), so the
11150        // engine's own flag stays off — setting it would be a second, redundant
11151        // gate that could silently no-op a confirmed execute.
11152        assert!(!opts.dry_run);
11153    }
11154
11155    #[test]
11156    fn log_rebase_check_records_the_pending_count_under_an_info_subscriber() {
11157        let req = RebaseRequest {
11158            requester_key: Some("win-3".into()),
11159            check: true,
11160            ..rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
11161        };
11162        let plan = worktree_rebase::Plan {
11163            fetches: vec![worktree_rebase::FetchOutcome {
11164                repo_root: PathBuf::from("/repo"),
11165                onto: "origin/main".into(),
11166                fetched: true,
11167                ok: false,
11168                detail: Some("host unreachable".into()),
11169            }],
11170            worktrees: vec![
11171                worktree_rebase::WorktreeOutcome {
11172                    path: PathBuf::from("/a"),
11173                    branch: Some("a".into()),
11174                    onto: "origin/main".into(),
11175                    result: worktree_rebase::RebaseResult::WouldRebase { behind: 2 },
11176                },
11177                worktree_rebase::WorktreeOutcome {
11178                    path: PathBuf::from("/b"),
11179                    branch: Some("b".into()),
11180                    onto: "origin/main".into(),
11181                    result: worktree_rebase::RebaseResult::UpToDate,
11182                },
11183            ],
11184        };
11185        let logs = capture_info(|| log_rebase_check(&req, &plan));
11186        assert!(logs.contains("rebase check"), "{logs}");
11187        assert!(logs.contains("win-3"), "{logs}");
11188        assert!(logs.contains("requested=2"), "{logs}");
11189        assert!(logs.contains("pending=1"), "{logs}");
11190        assert!(logs.contains("failed_fetches=1"), "{logs}");
11191    }
11192
11193    #[test]
11194    fn log_rebase_execute_counts_left_in_place_conflicts_separately() {
11195        // A CLI-style requester (no window key) logs the dash fallback.
11196        let req = rebase_req(vec![PathBuf::from("/a"), PathBuf::from("/b")]);
11197        let outcome = |result| worktree_rebase::WorktreeOutcome {
11198            path: PathBuf::from("/x"),
11199            branch: Some("x".into()),
11200            onto: "origin/main".into(),
11201            result,
11202        };
11203        let outcomes = vec![
11204            outcome(worktree_rebase::RebaseResult::Rebased { behind: 1 }),
11205            outcome(worktree_rebase::RebaseResult::Conflict {
11206                detail: "CONFLICT".into(),
11207                left_in_place: true,
11208            }),
11209            outcome(worktree_rebase::RebaseResult::Skipped {
11210                reason: worktree_rebase::SkipReason::Dirty,
11211            }),
11212        ];
11213        let logs = capture_info(|| log_rebase_execute(&req, &outcomes));
11214        assert!(logs.contains("rebase execute"), "{logs}");
11215        assert!(logs.contains("rebased=1"), "{logs}");
11216        assert!(logs.contains("conflicts=1"), "{logs}");
11217        assert!(logs.contains("left_in_place=1"), "{logs}");
11218        assert!(logs.contains("skipped=1"), "{logs}");
11219        assert!(logs.contains(r#"requester="-""#), "{logs}");
11220    }
11221
11222    // --- Push op (#1443) ---------------------------------------------------
11223
11224    /// A `PushRequest` with every field defaulted, for terse test construction.
11225    fn push_req(paths: Vec<PathBuf>) -> PushRequest {
11226        PushRequest {
11227            paths,
11228            requester_key: None,
11229            check: false,
11230            confirmed: false,
11231        }
11232    }
11233
11234    /// A bare `origin`, a local clone of `main`, and a linked `feature` worktree
11235    /// whose branch is published and then **rewritten** — i.e. exactly what a
11236    /// rebase leaves behind, the state `push` exists for.
11237    ///
11238    /// Shells out to `git` (so the push has a real remote to talk to) under the
11239    /// shared serialization guard the other git-heavy tests use.
11240    fn rewritten_worktree() -> (tempfile::TempDir, PathBuf, PathBuf) {
11241        // Held for the fixture only — that dozen-subprocess burst is what the lock
11242        // exists to cap — and released on return, before any `.await` in the test.
11243        let _guard = crate::git::worktree_batch::test_serial_lock();
11244        let root = tempfile::tempdir().unwrap();
11245        let origin = root.path().join("origin.git");
11246        let local = root.path().join("local");
11247        let wt = root.path().join("feature-wt");
11248        std::fs::create_dir_all(&origin).unwrap();
11249        std::fs::create_dir_all(&local).unwrap();
11250
11251        let git = |dir: &Path, args: &[&str]| {
11252            let out = crate::git::worktree_batch::run_git_in(
11253                &crate::git::resolve_git_binary(),
11254                dir,
11255                args,
11256            )
11257            .unwrap();
11258            assert!(
11259                out.status.success(),
11260                "git {args:?} failed: {}",
11261                String::from_utf8_lossy(&out.stderr)
11262            );
11263        };
11264
11265        git(&origin, &["init", "--bare", "-b", "main"]);
11266        git(&local, &["init", "-b", "main"]);
11267        git(&local, &["config", "user.name", "Test"]);
11268        git(&local, &["config", "user.email", "test@example.com"]);
11269        git(&local, &["config", "commit.gpgsign", "false"]);
11270        std::fs::write(local.join("f.txt"), "base\n").unwrap();
11271        git(&local, &["add", "f.txt"]);
11272        git(&local, &["commit", "-m", "base"]);
11273        git(
11274            &local,
11275            &["remote", "add", "origin", origin.to_str().unwrap()],
11276        );
11277        git(&local, &["push", "-u", "origin", "main"]);
11278        git(
11279            &local,
11280            &[
11281                "worktree",
11282                "add",
11283                "-b",
11284                "feature",
11285                wt.to_str().unwrap(),
11286                "main",
11287            ],
11288        );
11289        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
11290        git(&wt, &["add", "g.txt"]);
11291        git(&wt, &["commit", "-m", "work"]);
11292        git(&wt, &["push", "-u", "origin", "feature"]);
11293        // The rewrite: `feature` now diverges from `origin/feature`.
11294        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
11295
11296        (root, origin, std::fs::canonicalize(&wt).unwrap())
11297    }
11298
11299    /// The tip of `refname` in the bare origin, when it exists.
11300    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
11301        Repository::open_bare(origin)
11302            .unwrap()
11303            .refname_to_id(refname)
11304            .ok()
11305    }
11306
11307    #[tokio::test]
11308    async fn push_with_refuses_an_empty_selection() {
11309        // A bare `push` must be a usage error, never a silent mass-push.
11310        let svc = WorktreesService::new();
11311        let err = svc
11312            .push_with(push_req(Vec::new()), PathBuf::from("git"))
11313            .await
11314            .unwrap_err()
11315            .to_string();
11316        assert!(err.contains("at least one path"), "{err}");
11317    }
11318
11319    #[tokio::test]
11320    async fn push_with_phase_one_reports_without_publishing() {
11321        let (_root, origin, wt) = rewritten_worktree();
11322        let before = origin_tip(&origin, "refs/heads/feature");
11323
11324        let svc = WorktreesService::new();
11325        let reply = svc
11326            .push_with(
11327                PushRequest {
11328                    check: true,
11329                    ..push_req(vec![wt.clone()])
11330                },
11331                crate::git::resolve_git_binary(),
11332            )
11333            .await
11334            .unwrap();
11335
11336        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11337        assert_eq!(worktrees.len(), 1, "{reply}");
11338        assert_eq!(
11339            worktrees[0].get("status").and_then(Value::as_str),
11340            Some("would-force"),
11341            "{reply}"
11342        );
11343        assert!(
11344            reply.get("fetches").is_none(),
11345            "a push plan contacts no remote, so it reports no fetches: {reply}"
11346        );
11347        assert_eq!(
11348            origin_tip(&origin, "refs/heads/feature"),
11349            before,
11350            "phase 1 must publish nothing"
11351        );
11352        assert!(
11353            svc.registry.pushing_paths().is_empty(),
11354            "phase 1 must not mark a row as in flight"
11355        );
11356    }
11357
11358    #[tokio::test]
11359    async fn push_with_phase_two_force_pushes_and_clears_the_pushing_mark() {
11360        let (_root, origin, wt) = rewritten_worktree();
11361        let rewritten = Repository::open(&wt).unwrap().head().unwrap().target();
11362
11363        let svc = WorktreesService::new();
11364        let reply = svc
11365            .push_with(
11366                PushRequest {
11367                    confirmed: true,
11368                    ..push_req(vec![wt.clone()])
11369                },
11370                crate::git::resolve_git_binary(),
11371            )
11372            .await
11373            .unwrap();
11374
11375        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11376        assert_eq!(
11377            worktrees[0].get("status").and_then(Value::as_str),
11378            Some("pushed"),
11379            "{reply}"
11380        );
11381        assert_eq!(
11382            worktrees[0].get("forced").and_then(Value::as_bool),
11383            Some(true),
11384            "a rewritten branch is published under the lease: {reply}"
11385        );
11386        assert_eq!(
11387            origin_tip(&origin, "refs/heads/feature"),
11388            rewritten,
11389            "the remote must carry the rewritten tip"
11390        );
11391        assert!(
11392            svc.registry.pushing_paths().is_empty(),
11393            "the pushing mark must be cleared after the execute — a push writes no \
11394             on-disk state, so nothing else could ever correct a leftover"
11395        );
11396    }
11397
11398    #[tokio::test]
11399    async fn push_resolves_the_git_binary_for_itself() {
11400        // The public entry point, which `push_with` exists to let the other tests
11401        // bypass. Safe to call for real: a `check` never reaches a subprocess at
11402        // all, because planning a push contacts no remote.
11403        let (_root, origin, wt) = rewritten_worktree();
11404        let before = origin_tip(&origin, "refs/heads/feature");
11405
11406        let svc = WorktreesService::new();
11407        let reply = svc
11408            .push(PushRequest {
11409                check: true,
11410                ..push_req(vec![wt])
11411            })
11412            .await
11413            .unwrap();
11414
11415        assert_eq!(
11416            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
11417                .get("status")
11418                .and_then(Value::as_str),
11419            Some("would-force"),
11420            "{reply}"
11421        );
11422        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
11423    }
11424
11425    #[tokio::test]
11426    async fn push_with_defaults_to_report_only_without_confirmation() {
11427        // Neither `check` nor `confirmed`: the safe reading is "report", matching
11428        // `rebase`. A client that forgets the flag must never publish.
11429        let (_root, origin, wt) = rewritten_worktree();
11430        let before = origin_tip(&origin, "refs/heads/feature");
11431
11432        let svc = WorktreesService::new();
11433        let reply = svc
11434            .push_with(push_req(vec![wt]), crate::git::resolve_git_binary())
11435            .await
11436            .unwrap();
11437
11438        assert_eq!(
11439            reply.get("worktrees").and_then(Value::as_array).unwrap()[0]
11440                .get("status")
11441                .and_then(Value::as_str),
11442            Some("would-force"),
11443            "{reply}"
11444        );
11445        assert_eq!(origin_tip(&origin, "refs/heads/feature"), before);
11446    }
11447
11448    #[tokio::test]
11449    async fn push_with_refuses_to_force_the_remote_default_branch() {
11450        // The gate that inverts ADR-0060, enforced in the daemon rather than only
11451        // in the UI: a rewritten `main` is reported, never published.
11452        let (root, origin, _wt) = rewritten_worktree();
11453        let local = root.path().join("local");
11454        let before = origin_tip(&origin, "refs/heads/main");
11455        crate::git::worktree_batch::run_git_in(
11456            &crate::git::resolve_git_binary(),
11457            &local,
11458            &["commit", "--amend", "-m", "rewritten main"],
11459        )
11460        .unwrap();
11461
11462        let svc = WorktreesService::new();
11463        let reply = svc
11464            .push_with(
11465                PushRequest {
11466                    confirmed: true,
11467                    ..push_req(vec![local.clone()])
11468                },
11469                crate::git::resolve_git_binary(),
11470            )
11471            .await
11472            .unwrap();
11473
11474        let worktrees = reply.get("worktrees").and_then(Value::as_array).unwrap();
11475        assert_eq!(
11476            worktrees[0].get("reason").and_then(Value::as_str),
11477            Some("default-branch-force-push"),
11478            "{reply}"
11479        );
11480        assert_eq!(
11481            origin_tip(&origin, "refs/heads/main"),
11482            before,
11483            "the default branch's published history must be untouched"
11484        );
11485    }
11486
11487    #[test]
11488    fn log_push_check_separates_the_force_count_from_the_pending_count() {
11489        let req = PushRequest {
11490            requester_key: Some("win-7".into()),
11491            check: true,
11492            ..push_req(vec![PathBuf::from("/a"), PathBuf::from("/b")])
11493        };
11494        let outcome = |result| worktree_push::WorktreeOutcome {
11495            path: PathBuf::from("/x"),
11496            branch: Some("x".into()),
11497            remote: "origin".into(),
11498            remote_branch: "x".into(),
11499            result,
11500        };
11501        let plan = worktree_push::Plan {
11502            worktrees: vec![
11503                outcome(worktree_push::PushResult::WouldForce {
11504                    ahead: 1,
11505                    behind: 1,
11506                }),
11507                outcome(worktree_push::PushResult::WouldFastForward { ahead: 2 }),
11508                outcome(worktree_push::PushResult::Skipped {
11509                    reason: worktree_push::SkipReason::DefaultBranchForcePush,
11510                }),
11511            ],
11512        };
11513        let logs = capture_info(|| log_push_check(&req, &plan));
11514        assert!(logs.contains("push check"), "{logs}");
11515        assert!(logs.contains("pending=2"), "{logs}");
11516        assert!(logs.contains("forced=1"), "{logs}");
11517        assert!(logs.contains("skipped=1"), "{logs}");
11518        assert!(logs.contains(r#"requester="win-7""#), "{logs}");
11519    }
11520
11521    #[test]
11522    fn log_push_execute_counts_lease_refusals_separately() {
11523        let req = push_req(vec![PathBuf::from("/a")]);
11524        let outcome = |result| worktree_push::WorktreeOutcome {
11525            path: PathBuf::from("/x"),
11526            branch: Some("x".into()),
11527            remote: "origin".into(),
11528            remote_branch: "x".into(),
11529            result,
11530        };
11531        let outcomes = vec![
11532            outcome(worktree_push::PushResult::Pushed { forced: true }),
11533            outcome(worktree_push::PushResult::Pushed { forced: false }),
11534            outcome(worktree_push::PushResult::Created),
11535            outcome(worktree_push::PushResult::Rejected {
11536                detail: "stale info".into(),
11537                stale: true,
11538            }),
11539            outcome(worktree_push::PushResult::Rejected {
11540                detail: "pre-receive hook declined".into(),
11541                stale: false,
11542            }),
11543        ];
11544        let logs = capture_info(|| log_push_execute(&req, &outcomes));
11545        assert!(logs.contains("push execute"), "{logs}");
11546        assert!(logs.contains("pushed=2"), "{logs}");
11547        assert!(logs.contains("forced=1"), "{logs}");
11548        assert!(logs.contains("created=1"), "{logs}");
11549        assert!(logs.contains("rejected=2"), "{logs}");
11550        assert!(
11551            logs.contains("stale_rejected=1"),
11552            "a lease refusal is the interesting half of a rejection: {logs}"
11553        );
11554    }
11555
11556    #[test]
11557    fn worktree_entry_marks_a_path_the_registry_reports_as_pushing() {
11558        let dir = tempfile::tempdir().unwrap();
11559        let path = canonical(dir.path());
11560
11561        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
11562        assert!(!quiet.pushing);
11563        let json = serde_json::to_value(&quiet).unwrap();
11564        assert!(
11565            json.get("pushing").is_none(),
11566            "an idle row stays byte-identical for an older client: {json}"
11567        );
11568
11569        let busy = worktree_entry(
11570            &path,
11571            true,
11572            &HashMap::new(),
11573            &InFlight {
11574                pushing: [path.clone()].into(),
11575                rebasing: HashSet::new(),
11576            },
11577        );
11578        assert!(busy.pushing, "the registry's transient mark rides through");
11579        assert!(
11580            !busy.rebasing,
11581            "the two cues are independent — a push must not read as a rebase"
11582        );
11583        assert_eq!(
11584            serde_json::to_value(&busy).unwrap()["pushing"],
11585            serde_json::json!(true)
11586        );
11587    }
11588
11589    #[test]
11590    fn operation_slug_names_each_in_progress_state_and_none_when_clean() {
11591        assert_eq!(operation_slug(RepositoryState::Clean), None);
11592        assert_eq!(
11593            operation_slug(RepositoryState::Rebase).as_deref(),
11594            Some("rebase")
11595        );
11596        assert_eq!(
11597            operation_slug(RepositoryState::RebaseMerge).as_deref(),
11598            Some("rebase"),
11599            "the merge-backend rebase is still just a rebase to the user"
11600        );
11601        assert_eq!(
11602            operation_slug(RepositoryState::RebaseInteractive).as_deref(),
11603            Some("rebase-interactive")
11604        );
11605        assert_eq!(
11606            operation_slug(RepositoryState::Merge).as_deref(),
11607            Some("merge")
11608        );
11609        assert_eq!(
11610            operation_slug(RepositoryState::CherryPickSequence).as_deref(),
11611            Some("cherry-pick")
11612        );
11613        assert_eq!(
11614            operation_slug(RepositoryState::RevertSequence).as_deref(),
11615            Some("revert")
11616        );
11617        assert_eq!(
11618            operation_slug(RepositoryState::Bisect).as_deref(),
11619            Some("bisect")
11620        );
11621        assert_eq!(
11622            operation_slug(RepositoryState::ApplyMailboxOrRebase).as_deref(),
11623            Some("apply-mailbox")
11624        );
11625    }
11626
11627    #[test]
11628    fn git_status_omits_operation_for_a_clean_worktree() {
11629        let dir = tempfile::tempdir().unwrap();
11630        let _repo = diverging_repo(dir.path());
11631        assert_eq!(
11632            git_status(dir.path()).operation,
11633            None,
11634            "a clean worktree carries no operation, so the field stays off the wire"
11635        );
11636    }
11637
11638    #[test]
11639    fn worktree_entry_marks_a_path_the_registry_reports_as_rebasing() {
11640        let main_dir = tempfile::tempdir().unwrap();
11641        let repo = init_repo(main_dir.path());
11642        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
11643        repo.set_head("refs/heads/main").unwrap();
11644        let path = canonical(main_dir.path());
11645
11646        let quiet = worktree_entry(&path, true, &HashMap::new(), &InFlight::default());
11647        assert!(!quiet.rebasing);
11648        // Byte-identical for a pre-#1415 client: neither new field is serialized.
11649        let json = serde_json::to_value(&quiet).unwrap();
11650        assert!(json.get("rebasing").is_none(), "{json}");
11651        assert!(json.get("operation").is_none(), "{json}");
11652
11653        let busy = worktree_entry(
11654            &path,
11655            true,
11656            &HashMap::new(),
11657            &InFlight {
11658                rebasing: std::iter::once(path.clone()).collect(),
11659                pushing: HashSet::new(),
11660            },
11661        );
11662        assert!(busy.rebasing, "the registry's transient mark rides through");
11663        assert_eq!(
11664            serde_json::to_value(&busy).unwrap()["rebasing"],
11665            serde_json::Value::Bool(true)
11666        );
11667    }
11668
11669    #[test]
11670    fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
11671        assert_eq!(note_kinds(&[]), "-");
11672        assert_eq!(
11673            note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
11674            "dirty,untracked"
11675        );
11676    }
11677
11678    #[test]
11679    fn is_self_close_true_only_when_requester_owns_an_open_window() {
11680        let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
11681        assert!(is_self_close(Some("w1"), &windows));
11682        assert!(
11683            !is_self_close(Some("w3"), &windows),
11684            "requester owns no window"
11685        );
11686        assert!(!is_self_close(None, &windows), "no requester");
11687        assert!(!is_self_close(Some("w1"), &[]), "no open windows");
11688    }
11689
11690    #[test]
11691    fn log_and_map_removal_logs_and_maps_a_successful_prune() {
11692        let logs = capture_info(|| {
11693            let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::Pruned)).unwrap();
11694            assert_eq!(reply, json!({ "removed": true }));
11695        });
11696        assert!(
11697            logs.contains("worktrees close: linked worktree pruned"),
11698            "a successful prune must log an INFO audit line, got: {logs}"
11699        );
11700        assert!(
11701            logs.contains("/wt/feature"),
11702            "the target path must ride the line, got: {logs}"
11703        );
11704    }
11705
11706    #[test]
11707    fn log_and_map_removal_distinguishes_an_already_gone_no_op() {
11708        // The #1403 fix: an already-removed worktree still replies `removed: true`
11709        // (the row should go) but must NOT log the `pruned` line — it logs the
11710        // distinct `already-gone` outcome so the audit trail stops conflating the
11711        // two.
11712        let logs = capture_info(|| {
11713            let reply =
11714                log_and_map_removal(Path::new("/wt/feature"), Ok(Removal::AlreadyGone)).unwrap();
11715            assert_eq!(reply, json!({ "removed": true }));
11716        });
11717        assert!(
11718            logs.contains("worktrees close: nothing to prune, worktree already removed"),
11719            "an already-gone close must log its own outcome, got: {logs}"
11720        );
11721        assert!(
11722            !logs.contains("linked worktree pruned"),
11723            "an already-gone close must not claim it pruned, got: {logs}"
11724        );
11725    }
11726
11727    #[test]
11728    fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
11729        // ERROR is more severe than the INFO cap, so `capture_info` records it.
11730        let logs = capture_info(|| {
11731            let err = log_close_error(
11732                Path::new("/wt/feature"),
11733                "safety check",
11734                anyhow!("not a git worktree"),
11735            );
11736            assert_eq!(
11737                err.to_string(),
11738                "not a git worktree",
11739                "err propagates unchanged"
11740            );
11741        });
11742        assert!(
11743            logs.contains("worktrees close: safety check failed"),
11744            "a failed phase must log an ERROR audit line, got: {logs}"
11745        );
11746        assert!(
11747            logs.contains("not a git worktree"),
11748            "the cause must ride the line, got: {logs}"
11749        );
11750        assert!(
11751            logs.contains("/wt/feature"),
11752            "the target path must ride the line, got: {logs}"
11753        );
11754    }
11755
11756    #[test]
11757    fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
11758        let logs = capture_info(|| {
11759            let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
11760            assert!(err.is_err(), "a prune failure must propagate");
11761        });
11762        assert!(
11763            logs.contains("worktrees close: worktree prune failed"),
11764            "a prune failure must log a WARN audit line, got: {logs}"
11765        );
11766        assert!(
11767            logs.contains("locked"),
11768            "the failure cause must ride the line, got: {logs}"
11769        );
11770    }
11771
11772    #[test]
11773    fn log_safety_check_logs_the_verdict_and_owning_window_key() {
11774        let git = GitSafety {
11775            is_main: false,
11776            removable: true,
11777            risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
11778            info: vec![],
11779        };
11780        let logs = capture_info(|| {
11781            log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
11782        });
11783        assert!(
11784            logs.contains("worktrees close: safety check"),
11785            "phase-1 must log a safety-check line, got: {logs}"
11786        );
11787        assert!(
11788            logs.contains("/wt/feature"),
11789            "the path must ride the line, got: {logs}"
11790        );
11791        assert!(
11792            logs.contains("window_key=\"win-42\""),
11793            "the owning window key must ride the line, got: {logs}"
11794        );
11795        assert!(logs.contains("removable=true"), "got: {logs}");
11796        assert!(logs.contains("is_main=false"), "got: {logs}");
11797        assert!(logs.contains("open=true"), "got: {logs}");
11798        assert!(
11799            logs.contains("risks=dirty,untracked"),
11800            "the blocking risk kinds must ride the line, got: {logs}"
11801        );
11802    }
11803
11804    #[test]
11805    fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
11806        let git = GitSafety {
11807            is_main: false,
11808            removable: true,
11809            risks: vec![],
11810            info: vec![],
11811        };
11812        let logs = capture_info(|| {
11813            log_safety_check(Path::new("/wt/feature"), None, &git, false);
11814        });
11815        assert!(
11816            logs.contains("window_key=\"-\""),
11817            "no owning window → dash, got: {logs}"
11818        );
11819        assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
11820    }
11821
11822    #[test]
11823    fn log_executing_logs_the_routing_decision() {
11824        let logs = capture_info(|| {
11825            log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
11826        });
11827        assert!(
11828            logs.contains("worktrees close: executing"),
11829            "phase-2 must log the execute routing, got: {logs}"
11830        );
11831        assert!(
11832            logs.contains("requester=\"win-7\""),
11833            "the requester key must ride the line, got: {logs}"
11834        );
11835        assert!(logs.contains("remove=true"), "got: {logs}");
11836        assert!(logs.contains("self_close=false"), "got: {logs}");
11837        assert!(logs.contains("cross_window=3"), "got: {logs}");
11838    }
11839
11840    #[test]
11841    fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
11842        let logs = capture_info(|| {
11843            log_close_abort(
11844                Path::new("/wt/feature"),
11845                &anyhow!("window(s) did not close in time: win-9"),
11846            );
11847        });
11848        assert!(
11849            logs.contains("worktrees close: aborted"),
11850            "an abort must log a WARN audit line, got: {logs}"
11851        );
11852        assert!(
11853            logs.contains("/wt/feature"),
11854            "the path must ride the line, got: {logs}"
11855        );
11856        assert!(
11857            logs.contains("win-9"),
11858            "the still-open window must ride the line, got: {logs}"
11859        );
11860    }
11861
11862    #[test]
11863    fn log_window_closed_logs_the_no_removal_outcome() {
11864        let logs = capture_info(|| {
11865            log_window_closed(Path::new("/wt/feature"));
11866        });
11867        assert!(
11868            logs.contains("worktrees close: window closed, no removal"),
11869            "a remove:false close must log the no-removal outcome, got: {logs}"
11870        );
11871        assert!(
11872            logs.contains("/wt/feature"),
11873            "the path must ride the line, got: {logs}"
11874        );
11875    }
11876
11877    #[test]
11878    fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
11879        // The reorder (#1315) must still fully remove a worktree: both the
11880        // checked-out directory *and* the admin metadata git tracks it by, so it
11881        // no longer appears in `Repository::worktrees()`.
11882        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11883        let admin = main.path().join(".git").join("worktrees").join("feature");
11884        assert!(admin.exists(), "admin metadata should exist before removal");
11885
11886        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11887
11888        assert!(!wt_path.exists(), "the working directory should be gone");
11889        assert!(!admin.exists(), "the admin metadata should be pruned");
11890        let main_repo = Repository::open(main.path()).unwrap();
11891        assert_eq!(
11892            main_repo.worktrees().unwrap().len(),
11893            0,
11894            "git should no longer track the worktree"
11895        );
11896    }
11897
11898    #[test]
11899    fn remove_worktree_recovers_a_half_removed_orphan() {
11900        // The exact #1315 leftover: the old ordering deleted the admin metadata
11901        // first, then failed to rmdir the working tree, orphaning the directory
11902        // with a dangling `.git` gitlink. `remove_worktree` must clean it up
11903        // rather than error with "not a git worktree".
11904        let (main, _wtp, wt_path) = repo_with_linked_worktree();
11905        let admin = main.path().join(".git").join("worktrees").join("feature");
11906        // Simulate the half-removed state: admin gone, directory (+gitlink) left.
11907        std::fs::remove_dir_all(&admin).unwrap();
11908        assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
11909        assert!(
11910            Repository::open(&wt_path).is_err(),
11911            "the orphan should not open as a repo"
11912        );
11913
11914        assert_eq!(remove_worktree(&wt_path, &[]).unwrap(), Removal::Pruned);
11915        assert!(
11916            !wt_path.exists(),
11917            "the leftover directory should be removed"
11918        );
11919    }
11920
11921    /// A linked worktree whose working directory has been deleted out-of-band,
11922    /// leaving the main repo's `.git/worktrees/<name>/` admin entry behind — the
11923    /// exact #1403 orphan. Roots are canonicalized up front so the gone-path
11924    /// comparison in [`worktree_name_for_path`] (which cannot resolve a symlink on
11925    /// a vanished path) stays exact on macOS's `/var`→`/private/var` links.
11926    /// Returns `(main dir, canonical main root, wt parent dir, gone wt path,
11927    /// admin dir)`.
11928    fn orphaned_admin_worktree() -> (
11929        tempfile::TempDir,
11930        PathBuf,
11931        tempfile::TempDir,
11932        PathBuf,
11933        PathBuf,
11934    ) {
11935        let main_dir = tempfile::tempdir().unwrap();
11936        let main_root = main_dir.path().canonicalize().unwrap();
11937        let repo = init_repo(&main_root);
11938        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11939        repo.set_head("refs/heads/trunk").unwrap();
11940        let wt_parent = tempfile::tempdir().unwrap();
11941        let wt_path = wt_parent.path().canonicalize().unwrap().join("feature-wt");
11942        add_worktree(&repo, a, &wt_path, "feature");
11943        let admin = main_root.join(".git").join("worktrees").join("feature");
11944        assert!(admin.exists(), "admin metadata exists before the orphaning");
11945        // Delete the checkout out-of-band, leaving the admin entry `prunable`.
11946        std::fs::remove_dir_all(&wt_path).unwrap();
11947        (main_dir, main_root, wt_parent, wt_path, admin)
11948    }
11949
11950    fn window_on(folder: &Path) -> WindowEntry {
11951        WindowEntry {
11952            key: "w".to_string(),
11953            folders: vec![folder.to_path_buf()],
11954            repo: None,
11955            title: None,
11956            pid: None,
11957            last_seen: Utc::now(),
11958            last_active: Duration::ZERO,
11959        }
11960    }
11961
11962    #[test]
11963    fn remove_worktree_prunes_orphaned_admin_via_a_registered_window() {
11964        // #1403: working tree gone, admin present. An external worktree shares no
11965        // ancestor with its repo, so the owner is found via a live window on the
11966        // main repo — the same window whose repo enumeration showed the stuck row.
11967        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
11968
11969        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
11970
11971        assert_eq!(
11972            removed,
11973            Removal::Pruned,
11974            "the orphaned admin must be pruned"
11975        );
11976        assert!(!admin.exists(), "the admin metadata should be gone");
11977        let main_repo = Repository::open(&main_root).unwrap();
11978        assert!(
11979            main_repo.worktrees().unwrap().is_empty(),
11980            "git should no longer track the orphaned worktree"
11981        );
11982    }
11983
11984    #[test]
11985    fn remove_worktree_prunes_orphaned_admin_of_a_nested_worktree_via_ancestors() {
11986        // #1403 Option 1: a worktree nested under its own repo (the `.claude/
11987        // worktrees/<name>` shape that produced the reported orphans) is located
11988        // by walking the gone path's ancestors — no registered window needed.
11989        let main_dir = tempfile::tempdir().unwrap();
11990        let main_root = main_dir.path().canonicalize().unwrap();
11991        let repo = init_repo(&main_root);
11992        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
11993        repo.set_head("refs/heads/trunk").unwrap();
11994        // git2's `worktree()` creates the leaf but not intermediate parents.
11995        std::fs::create_dir_all(main_root.join(".nested")).unwrap();
11996        let wt_path = main_root.join(".nested").join("feature-wt");
11997        add_worktree(&repo, a, &wt_path, "feature");
11998        let admin = main_root.join(".git").join("worktrees").join("feature");
11999        std::fs::remove_dir_all(main_root.join(".nested")).unwrap();
12000
12001        let removed = remove_worktree(&wt_path, &[]).unwrap();
12002
12003        assert_eq!(removed, Removal::Pruned);
12004        assert!(!admin.exists(), "the admin metadata should be gone");
12005    }
12006
12007    #[test]
12008    fn remove_worktree_reports_already_gone_when_no_candidate_still_tracks_it() {
12009        // The other side of #1403: working tree gone AND admin already pruned. No
12010        // candidate repo tracks the path, so the honest outcome is `AlreadyGone`,
12011        // never a `pruned` lie.
12012        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12013        // Prune the admin entry too, so nothing remains to remove.
12014        std::fs::remove_dir_all(&admin).unwrap();
12015
12016        let removed = remove_worktree(&wt_path, &[window_on(&main_root)]).unwrap();
12017
12018        assert_eq!(removed, Removal::AlreadyGone);
12019    }
12020
12021    #[test]
12022    fn candidate_main_repos_finds_the_owner_via_ancestors_and_windows() {
12023        let (_main, main_root, _wtp, wt_path, _admin) = orphaned_admin_worktree();
12024        // External worktree: no ancestor is the repo, so only the window feed finds
12025        // it. The main root rides through, deduped to a single entry.
12026        let roots = candidate_main_repos(&wt_path, &[window_on(&main_root)]);
12027        assert!(
12028            roots.contains(&main_root),
12029            "the owning main repo must be a candidate, got: {roots:?}"
12030        );
12031    }
12032
12033    #[test]
12034    fn prune_orphaned_admin_skips_a_candidate_that_is_not_a_repo() {
12035        // A candidate root that does not open as a repo (a stale registry folder,
12036        // a deleted repo) is skipped rather than fatal; the real owner that
12037        // follows still prunes.
12038        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12039        let junk = tempfile::tempdir().unwrap();
12040
12041        let removed =
12042            prune_orphaned_admin(&wt_path, &[junk.path().to_path_buf(), main_root]).unwrap();
12043
12044        assert_eq!(removed, Removal::Pruned);
12045        assert!(
12046            !admin.exists(),
12047            "the real owner must still prune the orphan"
12048        );
12049    }
12050
12051    #[test]
12052    fn prune_orphaned_admin_skips_a_candidate_that_is_itself_a_worktree() {
12053        // A candidate that opens as a repo but is a *linked* worktree carries no
12054        // `.git/worktrees/` admin dir, so it is skipped; the main repo behind it
12055        // is the real owner. (A live sibling worktree is exactly such a candidate.)
12056        let main_dir = tempfile::tempdir().unwrap();
12057        let main_root = main_dir.path().canonicalize().unwrap();
12058        let repo = init_repo(&main_root);
12059        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12060        repo.set_head("refs/heads/trunk").unwrap();
12061        let wt_parent = tempfile::tempdir().unwrap();
12062        let wt_root = wt_parent.path().canonicalize().unwrap();
12063        let orphan = wt_root.join("orphan-wt");
12064        let sibling = wt_root.join("sibling-wt");
12065        add_worktree(&repo, a, &orphan, "orphan");
12066        add_worktree(&repo, a, &sibling, "sibling");
12067        let admin = main_root.join(".git").join("worktrees").join("orphan");
12068        std::fs::remove_dir_all(&orphan).unwrap();
12069
12070        // The live sibling worktree first (opens, but `is_worktree()` → skip), the
12071        // main repo second (the actual owner).
12072        let removed = prune_orphaned_admin(&orphan, &[sibling, main_root]).unwrap();
12073
12074        assert_eq!(removed, Removal::Pruned);
12075        assert!(
12076            !admin.exists(),
12077            "the orphan's admin metadata must be pruned"
12078        );
12079    }
12080
12081    #[test]
12082    fn prune_orphaned_admin_refuses_a_locked_orphan() {
12083        // Locking is an admin-dir file, independent of the (gone) checkout, so an
12084        // orphaned worktree can still be locked. The prune must refuse it —
12085        // "unlock first" — rather than force past, mirroring the live path.
12086        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12087        let main_repo = Repository::open(&main_root).unwrap();
12088        let name = worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap();
12089        main_repo
12090            .find_worktree(&name)
12091            .unwrap()
12092            .lock(Some("in use"))
12093            .unwrap();
12094
12095        let err = prune_orphaned_admin(&wt_path, &[main_root]).unwrap_err();
12096
12097        assert!(
12098            err.to_string().contains("locked"),
12099            "a locked orphan must be refused, got: {err:#}"
12100        );
12101        assert!(admin.exists(), "a refused prune must leave the admin entry");
12102    }
12103
12104    #[test]
12105    fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
12106        let (main, _wtp, wt_path) = repo_with_linked_worktree();
12107        // A live worktree: gitlink resolves → not an orphan.
12108        assert!(!is_orphaned_worktree(&wt_path));
12109        // The main checkout has a `.git` directory → not an orphan.
12110        assert!(!is_orphaned_worktree(main.path()));
12111        // Drop the admin metadata → the gitlink now dangles → orphan.
12112        std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
12113            .unwrap();
12114        assert!(is_orphaned_worktree(&wt_path));
12115    }
12116
12117    #[test]
12118    fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
12119        let tmp = tempfile::tempdir().unwrap();
12120        let missing = tmp.path().join("gone");
12121        assert!(remove_dir_all_retrying(&missing).is_ok());
12122    }
12123
12124    #[test]
12125    fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
12126        use std::io::Error;
12127        for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
12128            assert!(
12129                is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
12130                "errno {errno} is the concurrent-writer race and must be retried"
12131            );
12132        }
12133        // A hard failure must surface immediately rather than burn the backoff
12134        // waiting for a condition that will never clear.
12135        for errno in [
12136            nix::libc::EACCES,
12137            nix::libc::EPERM,
12138            nix::libc::EROFS,
12139            nix::libc::ENOTDIR,
12140        ] {
12141            assert!(
12142                !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
12143                "errno {errno} is permanent and must not be retried"
12144            );
12145        }
12146        // Not from the OS at all, so there is no errno to classify.
12147        assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
12148    }
12149
12150    #[test]
12151    fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
12152        // Removing a *file* as if it were a directory fails with ENOTDIR: not the
12153        // race, so it must fail on the first attempt with the original cause
12154        // attached, leaving the path untouched.
12155        let tmp = tempfile::tempdir().unwrap();
12156        let file = tmp.path().join("not-a-directory");
12157        std::fs::write(&file, b"x").unwrap();
12158
12159        let mut attempts = 0;
12160        let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
12161            attempts += 1;
12162            std::fs::remove_dir_all(&file)
12163        })
12164        .unwrap_err();
12165
12166        assert_eq!(attempts, 1, "a permanent error must not be retried");
12167        assert!(
12168            err.to_string()
12169                .contains("failed to remove worktree directory"),
12170            "unexpected error: {err:#}"
12171        );
12172        assert!(err.source().is_some(), "the io::Error cause is preserved");
12173        assert!(file.exists());
12174    }
12175
12176    #[test]
12177    fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
12178        // A writer that never quiesces: every sweep re-finds the directory
12179        // populated. Once the schedule runs out the ENOTEMPTY must surface rather
12180        // than the loop spinning forever.
12181        let tmp = tempfile::tempdir().unwrap();
12182        let mut attempts = 0;
12183        let backoff = [Duration::ZERO, Duration::ZERO];
12184        let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
12185            attempts += 1;
12186            Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
12187        })
12188        .unwrap_err();
12189
12190        // One attempt per delay, plus the initial one.
12191        assert_eq!(attempts, backoff.len() + 1);
12192        assert!(
12193            err.to_string()
12194                .contains("failed to remove worktree directory"),
12195            "unexpected error: {err:#}"
12196        );
12197    }
12198
12199    #[test]
12200    fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
12201        // The #1315 happy path, deterministically: the race clears partway through
12202        // the schedule and the removal then succeeds.
12203        let tmp = tempfile::tempdir().unwrap();
12204        let mut attempts = 0;
12205        let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
12206            attempts += 1;
12207            if attempts < 3 {
12208                Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
12209            } else {
12210                Ok(())
12211            }
12212        });
12213        assert!(result.is_ok(), "{result:?}");
12214        assert_eq!(attempts, 3);
12215    }
12216
12217    #[test]
12218    fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
12219        // A `.git` file that is readable but carries no `gitdir:` pointer is not
12220        // something we may delete.
12221        let tmp = tempfile::tempdir().unwrap();
12222        std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
12223        assert!(!is_orphaned_worktree(tmp.path()));
12224    }
12225
12226    #[test]
12227    fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
12228        // Neither a repo nor an orphan: refuse it rather than recursively deleting
12229        // whatever directory was passed in.
12230        let tmp = tempfile::tempdir().unwrap();
12231        let plain = tmp.path().join("plain");
12232        std::fs::create_dir(&plain).unwrap();
12233
12234        let err = remove_worktree(&plain, &[]).unwrap_err();
12235
12236        assert!(
12237            err.to_string().contains("not a git worktree"),
12238            "unexpected error: {err:#}"
12239        );
12240        assert!(plain.exists(), "a non-worktree path must be left alone");
12241    }
12242
12243    #[test]
12244    fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
12245        // Acceptance criterion (#1315): a language server / cargo still writing
12246        // into `target/` as the window closes makes the recursive rmdir race with
12247        // "Directory not empty". A background thread reproduces that by
12248        // repopulating `target/` for a bounded window; removal must retry past it
12249        // and still succeed once the writer stops.
12250        use std::sync::atomic::{AtomicBool, Ordering};
12251        use std::sync::Arc;
12252
12253        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12254        // Created once, here — never inside the writer loop. `create_dir_all`
12255        // rebuilds every *parent* component, so calling it per iteration let the
12256        // writer resurrect the worktree root the instant removal won the race,
12257        // failing the final assertion on a directory removal had correctly
12258        // deleted and the test itself put back (#1410).
12259        let nested = wt_path.join("target").join("nested");
12260        std::fs::create_dir_all(&nested).unwrap();
12261
12262        let stop = Arc::new(AtomicBool::new(false));
12263        let writer_stop = Arc::clone(&stop);
12264        let writer_dir = nested;
12265        let writer = std::thread::spawn(move || {
12266            let mut n = 0u64;
12267            // Churn hard for ~400ms (well under the ~2.75s retry budget), then
12268            // stop so a later removal pass finds the directory quiescent.
12269            let deadline = std::time::Instant::now() + Duration::from_millis(400);
12270            while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
12271                // Best-effort, and deliberately creating no directory: `fs::write`
12272                // is `File::create`, which never makes parents. While `target/`
12273                // survives these keep it non-empty — the ENOTEMPTY removal has to
12274                // retry past — and once removal wins they simply fail with ENOENT.
12275                let _ = std::fs::write(writer_dir.join(format!("artifact-{n}.tmp")), b"x");
12276                n += 1;
12277            }
12278        });
12279
12280        let result = remove_worktree(&wt_path, &[]);
12281        stop.store(true, Ordering::Relaxed);
12282        writer.join().unwrap();
12283
12284        assert!(
12285            result.is_ok(),
12286            "removal should retry past the writer: {result:?}"
12287        );
12288        assert!(!wt_path.exists(), "the worktree directory should be gone");
12289    }
12290
12291    #[tokio::test]
12292    async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
12293        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12294        // An untracked file in the worktree would be lost on removal.
12295        std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
12296
12297        let svc = WorktreesService::new();
12298        let report = svc
12299            .handle("close", json!({ "path": wt_path, "remove": true }))
12300            .await
12301            .unwrap();
12302        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12303        assert!(
12304            risks
12305                .iter()
12306                .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
12307            "expected an untracked risk: {report}"
12308        );
12309        // Still removable — the risk only means "confirm first", not "refuse".
12310        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
12311        // The unconfirmed check has no side effects.
12312        assert!(wt_path.exists());
12313    }
12314
12315    #[tokio::test]
12316    async fn close_confirmed_removes_a_dirty_worktree() {
12317        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12318        std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
12319        let svc = WorktreesService::new();
12320        // With confirmation, the risks are overridden and removal proceeds.
12321        let reply = svc
12322            .handle(
12323                "close",
12324                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12325            )
12326            .await
12327            .unwrap();
12328        assert_eq!(reply, json!({ "removed": true }));
12329        assert!(!wt_path.exists());
12330    }
12331
12332    #[tokio::test]
12333    async fn close_refuses_to_remove_the_main_working_tree() {
12334        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12335        let svc = WorktreesService::new();
12336        // Phase 1: the main tree reports not-removable, marked main.
12337        let report = svc
12338            .handle("close", json!({ "path": main.path(), "remove": true }))
12339            .await
12340            .unwrap();
12341        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
12342        assert_eq!(
12343            report.get("removable").and_then(Value::as_bool),
12344            Some(false)
12345        );
12346        // Phase 2: even a confirmed delete of the main tree is refused
12347        // defensively, and the directory is untouched.
12348        assert!(svc
12349            .handle(
12350                "close",
12351                json!({ "path": main.path(), "remove": true, "confirmed": true }),
12352            )
12353            .await
12354            .is_err());
12355        assert!(main.path().exists());
12356    }
12357
12358    #[tokio::test]
12359    async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
12360        // The case a naive impl would wrongly protect: a linked worktree checked
12361        // out on `main` (the default branch) is *still a linked worktree*, so it
12362        // is fully deletable — and `main` survives (removal never deletes a branch).
12363        let main_dir = tempfile::tempdir().unwrap();
12364        let repo = init_repo(main_dir.path());
12365        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
12366        repo.set_head("refs/heads/trunk").unwrap();
12367        let wt_parent = tempfile::tempdir().unwrap();
12368        let wt_path = wt_parent.path().join("main-wt");
12369        add_worktree(&repo, a, &wt_path, "main");
12370
12371        let svc = WorktreesService::new();
12372        let reply = svc
12373            .handle(
12374                "close",
12375                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12376            )
12377            .await
12378            .unwrap();
12379        assert_eq!(reply, json!({ "removed": true }));
12380        assert!(!wt_path.exists());
12381        // The `main` branch is untouched by the worktree removal.
12382        assert!(
12383            repo.find_branch("main", git2::BranchType::Local).is_ok(),
12384            "the default branch must survive worktree removal"
12385        );
12386    }
12387
12388    #[tokio::test]
12389    async fn close_is_idempotent_when_the_worktree_is_already_gone() {
12390        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12391        let svc = WorktreesService::new();
12392        // First removal succeeds.
12393        svc.handle(
12394            "close",
12395            json!({ "path": wt_path, "remove": true, "confirmed": true }),
12396        )
12397        .await
12398        .unwrap();
12399        // A second confirmed close of the now-missing path is a clean success,
12400        // not an error (a stale snapshot must not crash).
12401        let reply = svc
12402            .handle(
12403                "close",
12404                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12405            )
12406            .await
12407            .unwrap();
12408        assert_eq!(reply, json!({ "removed": true }));
12409    }
12410
12411    #[tokio::test]
12412    async fn close_prunes_an_orphaned_admin_entry_and_the_row_disappears() {
12413        // The #1403 end-to-end: working tree deleted out-of-band, admin entry left
12414        // behind so the tree view keeps showing a `prunable` row. Closing it must
12415        // actually prune the admin metadata (via the registered window's repo), not
12416        // report a `pruned` no-op that leaves the row stuck.
12417        let (_main, main_root, _wtp, wt_path, admin) = orphaned_admin_worktree();
12418        let svc = WorktreesService::new();
12419        // A live window on the main repo — the vantage point the stuck row is
12420        // enumerated from, and the one the prune locates the owner through.
12421        svc.handle(
12422            "register",
12423            register_payload("main-w", None, &main_root.display().to_string()),
12424        )
12425        .await
12426        .unwrap();
12427
12428        // Before: the orphaned linked worktree still shows alongside the main tree.
12429        let before = svc.handle("tree", Value::Null).await.unwrap();
12430        let worktrees_before = repos_of(&before)[0]["worktrees"].as_array().unwrap().len();
12431        assert_eq!(
12432            worktrees_before, 2,
12433            "the orphaned row is present before close"
12434        );
12435
12436        let reply = svc
12437            .handle(
12438                "close",
12439                json!({ "path": wt_path, "remove": true, "confirmed": true }),
12440            )
12441            .await
12442            .unwrap();
12443        assert_eq!(reply, json!({ "removed": true }));
12444
12445        // After: admin metadata pruned and the row is gone from the tree view.
12446        assert!(!admin.exists(), "the admin metadata must be pruned");
12447        let after = svc.handle("tree", Value::Null).await.unwrap();
12448        let worktrees_after = repos_of(&after)[0]["worktrees"].as_array().unwrap().len();
12449        assert_eq!(worktrees_after, 1, "only the main working tree remains");
12450    }
12451
12452    #[tokio::test]
12453    async fn close_safety_check_detects_detached_head_unreachable_commits() {
12454        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12455        // In the worktree, commit onto a detached HEAD so the new commit is
12456        // reachable from no ref — it would be GC'd on removal.
12457        let wt_repo = Repository::open(&wt_path).unwrap();
12458        let parent_oid = wt_repo.head().unwrap().target().unwrap();
12459        let parent = wt_repo.find_commit(parent_oid).unwrap();
12460        let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
12461        wt_repo.set_head_detached(orphan).unwrap();
12462
12463        let svc = WorktreesService::new();
12464        let report = svc
12465            .handle("close", json!({ "path": wt_path, "remove": true }))
12466            .await
12467            .unwrap();
12468        let risks = report.get("risks").and_then(Value::as_array).unwrap();
12469        assert!(
12470            risks
12471                .iter()
12472                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
12473            "expected an unreachable-commits risk: {report}"
12474        );
12475    }
12476
12477    #[tokio::test]
12478    async fn close_self_close_removes_when_the_requester_owns_the_target() {
12479        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12480        let svc = WorktreesService::new();
12481        // The requesting window itself has the worktree open: it is the only
12482        // owning window, so there is nothing to wait on — remove and reply, and
12483        // the extension closes its own window on `ok`.
12484        svc.handle(
12485            "register",
12486            json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
12487        )
12488        .await
12489        .unwrap();
12490        let reply = svc
12491            .handle(
12492                "close",
12493                json!({
12494                    "path": wt_path,
12495                    "remove": true,
12496                    "confirmed": true,
12497                    "requester_key": "w1",
12498                }),
12499            )
12500            .await
12501            .unwrap();
12502        assert_eq!(reply, json!({ "removed": true }));
12503        assert!(!wt_path.exists());
12504    }
12505
12506    #[tokio::test]
12507    async fn close_safety_check_surfaces_the_owning_window() {
12508        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12509        let svc = WorktreesService::new();
12510        // A multi-root window owns the target: the report surfaces its key and
12511        // folder count so the extension can warn "all N folders will close".
12512        svc.handle(
12513            "register",
12514            json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
12515        )
12516        .await
12517        .unwrap();
12518        let report = svc
12519            .handle("close", json!({ "path": wt_path, "remove": true }))
12520            .await
12521            .unwrap();
12522        assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
12523        assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
12524        assert_eq!(
12525            report.get("window_folder_count").and_then(Value::as_u64),
12526            Some(2)
12527        );
12528    }
12529
12530    #[tokio::test]
12531    async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
12532        let svc = WorktreesService::new();
12533        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12534            .await
12535            .unwrap();
12536        // No directive → a plain `{ known: true }`, byte-identical to before.
12537        assert_eq!(
12538            svc.handle("heartbeat", json!({ "key": "w1" }))
12539                .await
12540                .unwrap(),
12541            json!({ "known": true })
12542        );
12543        // Marked → the next heartbeat carries `close: true`, exactly once.
12544        svc.registry.mark_close_pending("w1");
12545        assert_eq!(
12546            svc.handle("heartbeat", json!({ "key": "w1" }))
12547                .await
12548                .unwrap(),
12549            json!({ "known": true, "close": true })
12550        );
12551        assert_eq!(
12552            svc.handle("heartbeat", json!({ "key": "w1" }))
12553                .await
12554                .unwrap(),
12555            json!({ "known": true })
12556        );
12557    }
12558
12559    // --- Reload op (#1417) -------------------------------------------------
12560
12561    #[tokio::test]
12562    async fn heartbeat_op_surfaces_a_pending_reload_directive_once() {
12563        let svc = WorktreesService::new();
12564        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12565            .await
12566            .unwrap();
12567        // Nothing pending → `reload` is absent, so a companion that predates
12568        // #1417 sees a byte-identical reply.
12569        assert_eq!(
12570            svc.handle("heartbeat", json!({ "key": "w1" }))
12571                .await
12572                .unwrap(),
12573            json!({ "known": true })
12574        );
12575        // Marked → the next heartbeat carries `reload: true`, exactly once.
12576        svc.registry.mark_reload_pending("w1");
12577        assert_eq!(
12578            svc.handle("heartbeat", json!({ "key": "w1" }))
12579                .await
12580                .unwrap(),
12581            json!({ "known": true, "reload": true })
12582        );
12583        assert_eq!(
12584            svc.handle("heartbeat", json!({ "key": "w1" }))
12585                .await
12586                .unwrap(),
12587            json!({ "known": true })
12588        );
12589    }
12590
12591    #[tokio::test]
12592    async fn heartbeat_op_carries_both_directives_when_both_are_pending() {
12593        let svc = WorktreesService::new();
12594        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12595            .await
12596            .unwrap();
12597        // Independent `if`s, not an `else`: both fields ride the same reply and
12598        // both are consumed, so neither directive can be stranded by the other.
12599        // The companion resolves the collision by checking `close` first.
12600        svc.registry.mark_close_pending("w1");
12601        svc.registry.mark_reload_pending("w1");
12602        assert_eq!(
12603            svc.handle("heartbeat", json!({ "key": "w1" }))
12604                .await
12605                .unwrap(),
12606            json!({ "known": true, "close": true, "reload": true })
12607        );
12608        assert_eq!(
12609            svc.handle("heartbeat", json!({ "key": "w1" }))
12610                .await
12611                .unwrap(),
12612            json!({ "known": true })
12613        );
12614    }
12615
12616    #[tokio::test]
12617    async fn reload_op_signals_live_windows_and_reports_unknown_keys() {
12618        let svc = WorktreesService::new();
12619        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12620            .await
12621            .unwrap();
12622        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
12623            .await
12624            .unwrap();
12625
12626        // A key with no live window is reported, never an error: a window
12627        // closing between the client listing and sending is routine.
12628        let reply = svc
12629            .handle("reload", json!({ "target_keys": ["w1", "w2", "ghost"] }))
12630            .await
12631            .unwrap();
12632        assert_eq!(
12633            reply,
12634            json!({ "requested": 3, "signalled": 2, "unknown": ["ghost"] })
12635        );
12636
12637        // Both live targets now have a directive waiting; the unknown one does
12638        // not (the daemon must not resurrect a key it never knew).
12639        assert!(svc.registry.take_reload_pending("w1"));
12640        assert!(svc.registry.take_reload_pending("w2"));
12641        assert!(!svc.registry.take_reload_pending("ghost"));
12642    }
12643
12644    #[tokio::test]
12645    async fn reload_op_dedupes_repeated_keys_and_accepts_an_empty_batch() {
12646        let svc = WorktreesService::new();
12647        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12648            .await
12649            .unwrap();
12650
12651        // A client repeating a key asks for one reload, not two — `requested`
12652        // counts distinct targets so the client's summary cannot overstate.
12653        assert_eq!(
12654            svc.handle("reload", json!({ "target_keys": ["w1", "w1"] }))
12655                .await
12656                .unwrap(),
12657            json!({ "requested": 1, "signalled": 1, "unknown": [] })
12658        );
12659
12660        // An empty batch is a no-op success, and a missing field is an empty
12661        // batch — the callers filter their targets before sending.
12662        assert_eq!(
12663            svc.handle("reload", json!({ "target_keys": [] }))
12664                .await
12665                .unwrap(),
12666            json!({ "requested": 0, "signalled": 0, "unknown": [] })
12667        );
12668        assert_eq!(
12669            svc.handle("reload", json!({})).await.unwrap(),
12670            json!({ "requested": 0, "signalled": 0, "unknown": [] })
12671        );
12672    }
12673
12674    #[tokio::test]
12675    async fn reload_op_directive_reaches_the_target_on_its_next_heartbeat() {
12676        let svc = WorktreesService::new();
12677        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12678            .await
12679            .unwrap();
12680        svc.handle("register", register_payload("w2", Some("r"), "/tmp/b"))
12681            .await
12682            .unwrap();
12683
12684        // The end-to-end contract: `reload` marks, the target's own heartbeat
12685        // delivers. Unlike `close`, nothing waits — the op has already returned.
12686        svc.handle("reload", json!({ "target_keys": ["w2"] }))
12687            .await
12688            .unwrap();
12689        assert_eq!(
12690            svc.handle("heartbeat", json!({ "key": "w2" }))
12691                .await
12692                .unwrap(),
12693            json!({ "known": true, "reload": true })
12694        );
12695        // A window that was not a target is untouched.
12696        assert_eq!(
12697            svc.handle("heartbeat", json!({ "key": "w1" }))
12698                .await
12699                .unwrap(),
12700            json!({ "known": true })
12701        );
12702    }
12703
12704    #[tokio::test]
12705    async fn reload_op_treats_an_unregistered_window_as_unknown() {
12706        let svc = WorktreesService::new();
12707        svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
12708            .await
12709            .unwrap();
12710        svc.handle("unregister", json!({ "key": "w1" }))
12711            .await
12712            .unwrap();
12713        // `list()` reaps on read, so a window that has gone away cannot be
12714        // signalled — it is reported instead.
12715        assert_eq!(
12716            svc.handle("reload", json!({ "target_keys": ["w1"] }))
12717                .await
12718                .unwrap(),
12719            json!({ "requested": 1, "signalled": 0, "unknown": ["w1"] })
12720        );
12721    }
12722
12723    #[tokio::test]
12724    async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
12725        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12726        let svc = Arc::new(WorktreesService::new());
12727        // A *different* window (not the requester) owns the target.
12728        svc.handle(
12729            "register",
12730            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12731        )
12732        .await
12733        .unwrap();
12734
12735        // Drive the destructive close concurrently: it marks w2 to close and
12736        // waits for it to unregister before removing.
12737        let svc2 = svc.clone();
12738        let path = wt_path.clone();
12739        let close = tokio::spawn(async move {
12740            svc2.handle(
12741                "close",
12742                json!({
12743                    "path": path,
12744                    "remove": true,
12745                    "confirmed": true,
12746                    "requester_key": "w1",
12747                }),
12748            )
12749            .await
12750        });
12751
12752        // Simulate w2's extension: its next heartbeat sees `close: true`, so it
12753        // closes its window and unregisters. Poll until the directive appears.
12754        let mut saw_close = false;
12755        for _ in 0..200 {
12756            let hb = svc
12757                .handle("heartbeat", json!({ "key": "w2" }))
12758                .await
12759                .unwrap();
12760            if hb.get("close").and_then(Value::as_bool) == Some(true) {
12761                saw_close = true;
12762                svc.handle("unregister", json!({ "key": "w2" }))
12763                    .await
12764                    .unwrap();
12765                break;
12766            }
12767            tokio::time::sleep(Duration::from_millis(5)).await;
12768        }
12769        assert!(saw_close, "w2 should have been told to close");
12770
12771        // Once w2 has unregistered, the close op removes the worktree.
12772        let reply = close.await.unwrap().unwrap();
12773        assert_eq!(reply, json!({ "removed": true }));
12774        assert!(!wt_path.exists());
12775    }
12776
12777    #[tokio::test]
12778    async fn await_windows_closed_times_out_when_a_window_never_closes() {
12779        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
12780        let svc = WorktreesService::new();
12781        svc.handle(
12782            "register",
12783            json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
12784        )
12785        .await
12786        .unwrap();
12787        // The owning window never unregisters: the wait gives up (with a short
12788        // timeout here) rather than block, and names the still-open window.
12789        let err = await_windows_closed(
12790            &svc.registry,
12791            &wt_path,
12792            Some("w1"),
12793            Duration::from_millis(150),
12794            Duration::from_millis(25),
12795        )
12796        .await
12797        .unwrap_err();
12798        assert!(
12799            err.to_string().contains("w2"),
12800            "error names the window: {err}"
12801        );
12802        // The requester itself is excluded, so a self-only owner returns at once.
12803        await_windows_closed(
12804            &svc.registry,
12805            &wt_path,
12806            Some("w2"),
12807            Duration::from_millis(150),
12808            Duration::from_millis(25),
12809        )
12810        .await
12811        .unwrap();
12812    }
12813
12814    // --- A close's directives do not outlive it (#2139) --------------------
12815
12816    /// A service with window `key` registered on `folder`, plus the keys list
12817    /// `signal_and_await_close` takes.
12818    async fn service_with_window(key: &str, folder: &Path) -> (WorktreesService, Vec<String>) {
12819        let svc = WorktreesService::new();
12820        svc.handle("register", json!({ "key": key, "folders": [folder] }))
12821            .await
12822            .unwrap();
12823        (svc, vec![key.to_string()])
12824    }
12825
12826    /// What `key`'s next heartbeat reply carries.
12827    async fn heartbeat_reply(svc: &WorktreesService, key: &str) -> Value {
12828        svc.handle("heartbeat", json!({ "key": key }))
12829            .await
12830            .unwrap()
12831    }
12832
12833    #[tokio::test]
12834    async fn a_timed_out_close_leaves_no_directive_for_the_slow_window() {
12835        let dir = tempfile::tempdir().unwrap();
12836        let (svc, others) = service_with_window("w2", dir.path()).await;
12837
12838        // The owning window never unregisters, so the wait gives up.
12839        let err = svc
12840            .signal_and_await_close(
12841                dir.path(),
12842                Some("w1"),
12843                &others,
12844                Duration::from_millis(150),
12845                Duration::from_millis(25),
12846            )
12847            .await
12848            .unwrap_err();
12849        assert!(err.to_string().contains("w2"), "names the window: {err}");
12850
12851        // The user was told the close failed, so when the slow window finally
12852        // heartbeats it must not be told to close itself.
12853        assert_eq!(
12854            heartbeat_reply(&svc, "w2").await,
12855            json!({ "known": true }),
12856            "a failed close must have no later side effect"
12857        );
12858    }
12859
12860    #[tokio::test]
12861    async fn a_failed_close_leaves_a_reload_directive_alone() {
12862        let dir = tempfile::tempdir().unwrap();
12863        let (svc, others) = service_with_window("w2", dir.path()).await;
12864        // An unrelated reload signalled to the same window beforehand.
12865        svc.registry.mark_reload_pending("w2");
12866
12867        svc.signal_and_await_close(
12868            dir.path(),
12869            Some("w1"),
12870            &others,
12871            Duration::from_millis(100),
12872            Duration::from_millis(25),
12873        )
12874        .await
12875        .unwrap_err();
12876
12877        assert_eq!(
12878            heartbeat_reply(&svc, "w2").await,
12879            json!({ "known": true, "reload": true }),
12880            "only the close directive is withdrawn"
12881        );
12882    }
12883
12884    #[tokio::test]
12885    async fn a_close_whose_window_is_reaped_still_closes_it_if_it_returns() {
12886        let dir = tempfile::tempdir().unwrap();
12887        let (svc, others) = service_with_window("w2", dir.path()).await;
12888        // The window goes silent past the TTL (in awake time), so `list()` reaps
12889        // it and the wait counts it closed — the op then goes on to remove the
12890        // worktree. A hung window that comes back must not be left open on a
12891        // folder that is gone, so a successful close keeps its directive.
12892        svc.registry.advance_clock(Duration::from_secs(3600));
12893
12894        svc.signal_and_await_close(
12895            dir.path(),
12896            Some("w1"),
12897            &others,
12898            Duration::from_secs(5),
12899            Duration::from_millis(25),
12900        )
12901        .await
12902        .unwrap();
12903
12904        assert_eq!(
12905            heartbeat_reply(&svc, "w2").await,
12906            json!({ "known": false, "close": true }),
12907            "it is told to close on the heartbeat that answers `known: false`"
12908        );
12909    }
12910
12911    #[tokio::test]
12912    async fn a_failed_close_also_withdraws_the_directive_of_a_window_reaped_meanwhile() {
12913        let dir = tempfile::tempdir().unwrap();
12914        let (svc, _) = service_with_window("w2", dir.path()).await;
12915        // `w2` ages out, then `w3` registers: the wait counts `w2` closed and
12916        // times out on `w3`. Both were signalled, and the op failed, so neither
12917        // may close itself later — including the reaped one if it returns.
12918        svc.registry.advance_clock(Duration::from_secs(3600));
12919        svc.handle("register", json!({ "key": "w3", "folders": [dir.path()] }))
12920            .await
12921            .unwrap();
12922
12923        let err = svc
12924            .signal_and_await_close(
12925                dir.path(),
12926                Some("w1"),
12927                &["w2".to_string(), "w3".to_string()],
12928                Duration::from_millis(100),
12929                Duration::from_millis(25),
12930            )
12931            .await
12932            .unwrap_err();
12933        assert!(
12934            err.to_string().contains("w3"),
12935            "names the live window: {err}"
12936        );
12937
12938        assert_eq!(heartbeat_reply(&svc, "w3").await, json!({ "known": true }));
12939        assert_eq!(heartbeat_reply(&svc, "w2").await, json!({ "known": false }));
12940    }
12941
12942    #[tokio::test]
12943    async fn a_successful_close_still_signals_the_window_once() {
12944        let dir = tempfile::tempdir().unwrap();
12945        let svc = Arc::new(WorktreesService::new());
12946        svc.handle("register", json!({ "key": "w2", "folders": [dir.path()] }))
12947            .await
12948            .unwrap();
12949
12950        let path = dir.path().to_path_buf();
12951        let waiter = {
12952            let svc = svc.clone();
12953            tokio::spawn(async move {
12954                svc.signal_and_await_close(
12955                    &path,
12956                    Some("w1"),
12957                    &["w2".to_string()],
12958                    Duration::from_secs(10),
12959                    Duration::from_millis(10),
12960                )
12961                .await
12962            })
12963        };
12964
12965        // The window's heartbeat is handed the directive while the op waits, then
12966        // it closes and unregisters.
12967        let mut saw_close = false;
12968        for _ in 0..400 {
12969            if heartbeat_reply(&svc, "w2").await.get("close") == Some(&Value::Bool(true)) {
12970                saw_close = true;
12971                break;
12972            }
12973            tokio::time::sleep(Duration::from_millis(5)).await;
12974        }
12975        assert!(saw_close, "w2 should have been told to close");
12976        svc.handle("unregister", json!({ "key": "w2" }))
12977            .await
12978            .unwrap();
12979
12980        waiter.await.unwrap().unwrap();
12981        // Delivered exactly once: nothing is left for a window that re-registers.
12982        svc.handle("register", json!({ "key": "w2", "folders": [dir.path()] }))
12983            .await
12984            .unwrap();
12985        assert_eq!(heartbeat_reply(&svc, "w2").await, json!({ "known": true }));
12986    }
12987
12988    #[tokio::test]
12989    async fn close_window_without_remove_replies_closed_and_never_deletes() {
12990        let (main, _wtp, _wt_path) = repo_with_linked_worktree();
12991        let svc = WorktreesService::new();
12992        // "Close Window" on the main tree: no git inspection, no removal.
12993        let reply = svc
12994            .handle("close", json!({ "path": main.path(), "remove": false }))
12995            .await
12996            .unwrap();
12997        assert_eq!(reply, json!({ "closed": true }));
12998        assert!(main.path().exists());
12999    }
13000
13001    #[tokio::test]
13002    async fn close_safety_check_flags_modified_tracked_files() {
13003        // A tracked file, checked out into the linked worktree, then modified —
13004        // its content is lost on removal, so it is a `dirty` risk (distinct from
13005        // the untracked case).
13006        let main_dir = tempfile::tempdir().unwrap();
13007        let repo = init_repo(main_dir.path());
13008        let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
13009        repo.set_head("refs/heads/trunk").unwrap();
13010        let wt_parent = tempfile::tempdir().unwrap();
13011        let wt_path = wt_parent.path().join("feature-wt");
13012        add_worktree(&repo, a, &wt_path, "feature");
13013        std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
13014
13015        let svc = WorktreesService::new();
13016        let report = svc
13017            .handle("close", json!({ "path": wt_path, "remove": true }))
13018            .await
13019            .unwrap();
13020        let risks = report.get("risks").and_then(Value::as_array).unwrap();
13021        assert!(
13022            risks
13023                .iter()
13024                .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
13025            "expected a dirty risk: {report}"
13026        );
13027    }
13028
13029    #[tokio::test]
13030    async fn close_safety_check_flags_an_in_progress_operation() {
13031        // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
13032        // non-Clean (interrupted merge) state — its progress is lost on removal.
13033        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13034        let wt_repo = Repository::open(&wt_path).unwrap();
13035        let head = wt_repo.head().unwrap().target().unwrap();
13036        std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
13037        assert_ne!(wt_repo.state(), RepositoryState::Clean);
13038
13039        let svc = WorktreesService::new();
13040        let report = svc
13041            .handle("close", json!({ "path": wt_path, "remove": true }))
13042            .await
13043            .unwrap();
13044        let risks = report.get("risks").and_then(Value::as_array).unwrap();
13045        assert!(
13046            risks
13047                .iter()
13048                .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
13049            "expected an in-progress risk: {report}"
13050        );
13051    }
13052
13053    #[tokio::test]
13054    async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
13055        // A linked worktree on `feature`, which tracks `origin/feature` and is one
13056        // commit ahead. The unpushed commit is INFO (the branch — and thus the
13057        // commit — survives removal), never a blocking risk.
13058        let main_dir = tempfile::tempdir().unwrap();
13059        let repo = init_repo(main_dir.path());
13060        let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
13061        repo.set_head("refs/heads/trunk").unwrap();
13062        let a_commit = repo.find_commit(a).unwrap();
13063        repo.branch("feature", &a_commit, false).unwrap();
13064        repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
13065            .unwrap();
13066        // `feature` advances one commit past `origin/feature`.
13067        empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
13068        drop(a_commit);
13069        let mut cfg = repo.config().unwrap();
13070        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
13071            .unwrap();
13072        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
13073            .unwrap();
13074        cfg.set_str("branch.feature.remote", "origin").unwrap();
13075        cfg.set_str("branch.feature.merge", "refs/heads/feature")
13076            .unwrap();
13077        // A worktree on the existing `feature` branch (not created fresh, so it
13078        // keeps the ahead-of-upstream divergence).
13079        let wt_parent = tempfile::tempdir().unwrap();
13080        let wt_path = wt_parent.path().join("feature-wt");
13081        let reference = repo.find_reference("refs/heads/feature").unwrap();
13082        let mut opts = git2::WorktreeAddOptions::new();
13083        opts.reference(Some(&reference));
13084        repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
13085
13086        let svc = WorktreesService::new();
13087        let report = svc
13088            .handle("close", json!({ "path": wt_path, "remove": true }))
13089            .await
13090            .unwrap();
13091        // Unpushed commits appear as `info`, and the worktree is still cleanly
13092        // removable with no blocking risks.
13093        let info = report.get("info").and_then(Value::as_array).unwrap();
13094        assert!(
13095            info.iter()
13096                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
13097            "expected an unpushed info note: {report}"
13098        );
13099        assert!(
13100            report
13101                .get("risks")
13102                .and_then(Value::as_array)
13103                .unwrap()
13104                .is_empty(),
13105            "unpushed commits alone must not block: {report}"
13106        );
13107        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13108    }
13109
13110    #[tokio::test]
13111    async fn close_safety_check_ignores_gitignored_files() {
13112        // With `.gitignore` committed, an ignored artifact is the only worktree
13113        // change — it must not count as untracked (it is regenerable), so the
13114        // worktree stays cleanly removable with no risks.
13115        let main_dir = tempfile::tempdir().unwrap();
13116        let repo = init_repo(main_dir.path());
13117        let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
13118        repo.set_head("refs/heads/trunk").unwrap();
13119        let wt_parent = tempfile::tempdir().unwrap();
13120        let wt_path = wt_parent.path().join("feature-wt");
13121        add_worktree(&repo, a, &wt_path, "feature");
13122        std::fs::create_dir(wt_path.join("build")).unwrap();
13123        std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
13124
13125        let svc = WorktreesService::new();
13126        let report = svc
13127            .handle("close", json!({ "path": wt_path, "remove": true }))
13128            .await
13129            .unwrap();
13130        assert!(
13131            report
13132                .get("risks")
13133                .and_then(Value::as_array)
13134                .unwrap()
13135                .is_empty(),
13136            "a gitignored file must not create a risk: {report}"
13137        );
13138        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13139    }
13140
13141    #[tokio::test]
13142    async fn close_safety_check_treats_a_missing_path_as_already_removed() {
13143        // The phase-1 check on a path that no longer exists reports it removable
13144        // with no risks (so the idempotent execute proceeds with no dialog).
13145        let svc = WorktreesService::new();
13146        let report = svc
13147            .handle(
13148                "close",
13149                json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
13150            )
13151            .await
13152            .unwrap();
13153        assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
13154        assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
13155        assert!(report
13156            .get("risks")
13157            .and_then(Value::as_array)
13158            .unwrap()
13159            .is_empty());
13160        let info = report.get("info").and_then(Value::as_array).unwrap();
13161        assert!(info
13162            .iter()
13163            .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
13164    }
13165
13166    #[tokio::test]
13167    async fn close_phase1_errors_on_a_non_git_worktree_path() {
13168        // An existing directory that is *not* a git worktree makes `git_safety`
13169        // fail; the error must propagate (rather than delete an unknown dir),
13170        // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
13171        // audit line itself is unit-tested via `log_close_error`.
13172        let dir = tempfile::tempdir().unwrap();
13173        let svc = WorktreesService::new();
13174        let result = svc
13175            .handle("close", json!({ "path": dir.path(), "remove": true }))
13176            .await;
13177        assert!(
13178            result.is_err(),
13179            "a non-git-worktree target must error the safety check, got: {result:?}"
13180        );
13181    }
13182
13183    #[tokio::test]
13184    async fn close_refuses_a_locked_worktree() {
13185        // A locked worktree (git worktree lock) must be refused, not forced past
13186        // (failure mode #6), and left on disk.
13187        let (main, _wtp, wt_path) = repo_with_linked_worktree();
13188        let main_repo = Repository::open(main.path()).unwrap();
13189        main_repo
13190            .find_worktree("feature")
13191            .unwrap()
13192            .lock(Some("under test"))
13193            .unwrap();
13194
13195        let svc = WorktreesService::new();
13196        let err = svc
13197            .handle(
13198                "close",
13199                json!({ "path": wt_path, "remove": true, "confirmed": true }),
13200            )
13201            .await
13202            .unwrap_err();
13203        assert!(
13204            err.to_string().contains("locked"),
13205            "expected a locked error: {err}"
13206        );
13207        assert!(wt_path.exists(), "a locked worktree must not be removed");
13208    }
13209
13210    #[tokio::test]
13211    async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
13212        // A detached HEAD that still sits on a commit a branch points to loses
13213        // nothing on removal, so it must NOT produce an unreachable-commits risk
13214        // (the false-positive the reachability walk guards against).
13215        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13216        let wt_repo = Repository::open(&wt_path).unwrap();
13217        // The worktree is on `feature`; detach HEAD onto its current tip, which
13218        // the `feature` branch still references.
13219        let tip = wt_repo.head().unwrap().target().unwrap();
13220        wt_repo.set_head_detached(tip).unwrap();
13221        assert!(wt_repo.head_detached().unwrap());
13222
13223        let svc = WorktreesService::new();
13224        let report = svc
13225            .handle("close", json!({ "path": wt_path, "remove": true }))
13226            .await
13227            .unwrap();
13228        let risks = report.get("risks").and_then(Value::as_array).unwrap();
13229        assert!(
13230            !risks
13231                .iter()
13232                .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
13233            "a detached HEAD reachable from a branch must not be flagged: {report}"
13234        );
13235    }
13236
13237    #[test]
13238    fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
13239        let (main, _wtp, wt_path) = repo_with_linked_worktree();
13240        let main_repo = Repository::open(main.path()).unwrap();
13241        // The real linked worktree resolves to its registered name.
13242        assert_eq!(
13243            worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
13244            "feature"
13245        );
13246        // A path that is not one of this repo's worktrees is the defensive
13247        // "not registered" error (the guard behind removal).
13248        let err =
13249            worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
13250        assert!(
13251            err.to_string().contains("not registered"),
13252            "expected a not-registered error: {err}"
13253        );
13254    }
13255
13256    #[test]
13257    fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
13258        // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
13259        // rather than sinking the whole safety check.
13260        let (_main, _wtp, wt_path) = repo_with_linked_worktree();
13261        let repo = Repository::open(&wt_path).unwrap();
13262        std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
13263        // Confirm the corruption actually breaks status enumeration, so the
13264        // count is exercising the error-degradation branch (not an empty repo).
13265        assert!(
13266            repo.statuses(Some(&mut StatusOptions::new())).is_err(),
13267            "a corrupt index should make statuses() fail"
13268        );
13269        assert_eq!(count_dirty_untracked(&repo), (0, 0));
13270    }
13271}