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
33use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use anyhow::{anyhow, bail, Context, Result};
41use chrono::{DateTime, Utc};
42
43use crate::github_rate_limit::{
44 resolve_rate_limit_with, RateLimitCache, RateLimitResource, RateLimitSnapshot,
45};
46use crate::pr_status::{PrBadge, PrCheckState, PrResolution, PrStatusCache, PrTarget};
47use async_trait::async_trait;
48use git2::{Repository, RepositoryState, Status, StatusOptions, WorktreeLockStatus};
49use serde::{Deserialize, Serialize};
50use serde_json::{json, Value};
51use tokio::sync::watch;
52use tokio::sync::Mutex as AsyncMutex;
53use tokio::task::JoinHandle;
54use tokio_util::sync::CancellationToken;
55
56use crate::daemon::service::{
57 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
58};
59use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
60
61/// The worktrees service name (the control-socket routing key).
62pub const SERVICE_NAME: &str = "worktrees";
63
64/// The tray submenu title.
65const SUBMENU_TITLE: &str = "Worktrees";
66
67/// Environment override for the VS Code launcher used by the "focus" tray
68/// action, for when the daemon runs under launchd with a minimal `PATH`.
69const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
70
71/// Environment override for [`menu_refresh_interval`] (whole seconds; a blank,
72/// non-numeric, or `0` value falls back to [`DEFAULT_MENU_REFRESH_INTERVAL`]).
73const ENV_MENU_REFRESH_INTERVAL: &str = "OMNI_DEV_DAEMON_MENU_REFRESH";
74
75/// Default cadence at which the background task recomputes the tray menu snapshot
76/// off the main thread when `OMNI_DEV_DAEMON_MENU_REFRESH` is unset. The macOS
77/// tray polls `menu()` ~1 Hz and always serves this cache, never doing git I/O on
78/// the GUI thread (which would peg a core and stall shutdown — the #1186
79/// regression); the interval only governs how stale that cached branch/sync state
80/// may be when the menu is opened.
81///
82/// Raised from 2 s to 10 s (#1305): this refresh is an independent per-window git
83/// walk that the subscription-stream coalescing (#1303) never touched — it was
84/// the dominant idle-CPU cost — so relaxing it cuts that cost ~5× while leaving
85/// menu open-latency unchanged (the cache still serves instantly).
86const DEFAULT_MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
87
88/// The resolved tray menu-refresh cadence: `OMNI_DEV_DAEMON_MENU_REFRESH` (whole
89/// seconds) when valid, else [`DEFAULT_MENU_REFRESH_INTERVAL`].
90fn menu_refresh_interval() -> Duration {
91 crate::daemon::server::duration_secs_from_env(
92 ENV_MENU_REFRESH_INTERVAL,
93 DEFAULT_MENU_REFRESH_INTERVAL,
94 )
95}
96
97/// Environment override for [`pr_poll_interval`] — the cadence at which the PR
98/// badge poller re-asks GitHub **while a badge is still pending** (whole seconds;
99/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_POLL_INTERVAL`]).
100const ENV_PR_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_PR_POLL";
101
102/// Default cadence for the PR badge poller while CI is in flight (#1337).
103///
104/// Matches `gh pr checks --watch`, which uses 10 s when a human is actively
105/// watching a run — which is exactly this situation. It is affordable because the
106/// poll costs **1 point** regardless of how many repos, worktrees, or windows are
107/// open: 10 s sustained is ~360 points/hour against a 5,000/hour budget, and only
108/// while something is actually pending.
109const DEFAULT_PR_POLL_INTERVAL: Duration = Duration::from_secs(10);
110
111/// The ceiling the poller backs off to once every badge is terminal.
112///
113/// Nothing is expected to change, so this is a liveness heartbeat, not a watch.
114/// The 30-minute figure is the cross-tool consensus for background PR polling
115/// (vscode-pull-request-github's backoff ceiling, gh-dash's and GitLens's
116/// defaults). The backoff exists for battery and wakeups rather than budget — at
117/// 1 point per poll the budget never binds.
118const MAX_PR_POLL_INTERVAL: Duration = Duration::from_secs(30 * 60);
119
120/// How long after fresh work (a push, or an added target) the poller holds its
121/// fast [`DEFAULT_PR_POLL_INTERVAL`] cadence before escalating *within* pending
122/// (#1389, fix 5). Two minutes covers the window where a just-pushed run is most
123/// likely to report; past it, a still-pending badge (a long build, a zombie suite)
124/// is watched more cheaply. See [`next_pr_poll_delay`].
125const PENDING_FAST_WINDOW: Duration = Duration::from_secs(2 * 60);
126
127/// The ceiling the poller escalates to *while still pending* once
128/// [`PENDING_FAST_WINDOW`] has passed (#1389, fix 5). Below the terminal
129/// [`MAX_PR_POLL_INTERVAL`] because a pending badge is expected to change soon,
130/// just not soon enough to justify pinning `base` — 60 s caps a 20-minute CI run
131/// at ~50 calls instead of ~120.
132const PENDING_MAX_INTERVAL: Duration = Duration::from_secs(60);
133
134/// The cadence floor the poller is held to while the shared GitHub budget is
135/// at/over [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT) (#1389, fix 6).
136/// Five minutes makes the daemon's contribution to an already-strained budget
137/// negligible while still recovering promptly once the window resets — a pause in
138/// all but name. See [`budget_throttled_delay`].
139const BUDGET_THROTTLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
140
141/// Environment override for [`pr_debounce_interval`] — the settle window the PR
142/// poller waits after the change-notify fires before snapshotting (whole seconds;
143/// a blank, non-numeric, or `0` value falls back to [`DEFAULT_PR_DEBOUNCE`]).
144const ENV_PR_DEBOUNCE: &str = "OMNI_DEV_DAEMON_PR_DEBOUNCE";
145
146/// Default settle window for the PR-poll change-notify debounce (#1389, fix 2).
147///
148/// A VS Code restart unregisters then re-registers its windows one-by-one over
149/// several seconds, each bump waking the poller; a daemon restart re-registers the
150/// same way. Waiting for ~2 s of quiet before snapshotting collapses the whole
151/// storm into **one** fetch on the final watch set instead of one per window.
152const DEFAULT_PR_DEBOUNCE: Duration = Duration::from_secs(2);
153
154/// The resolved PR-poll cadence: `OMNI_DEV_DAEMON_PR_POLL` (whole seconds) when
155/// valid, else [`DEFAULT_PR_POLL_INTERVAL`].
156fn pr_poll_interval() -> Duration {
157 crate::daemon::server::duration_secs_from_env(ENV_PR_POLL_INTERVAL, DEFAULT_PR_POLL_INTERVAL)
158}
159
160/// The resolved PR-poll debounce settle window: `OMNI_DEV_DAEMON_PR_DEBOUNCE`
161/// (whole seconds) when valid, else [`DEFAULT_PR_DEBOUNCE`].
162fn pr_debounce_interval() -> Duration {
163 crate::daemon::server::duration_secs_from_env(ENV_PR_DEBOUNCE, DEFAULT_PR_DEBOUNCE)
164}
165
166/// Environment override for [`open_pr_ttl`] — how long the daemon reuses a repo's
167/// `gh pr list` result before re-fetching (whole seconds; a blank, non-numeric, or
168/// `0` value falls back to [`DEFAULT_OPEN_PR_TTL`]).
169const ENV_OPEN_PR_TTL: &str = "OMNI_DEV_DAEMON_OPEN_PR_TTL";
170
171/// Default TTL for the shared open-PR cache (#1389, fix 7). Matches the extension's
172/// former per-window `gh pr list` cache (#1296): serving "Open Pull Request…" — and
173/// the extension's transient badge fallback — from the daemon means N windows now
174/// dedupe to **one** counted `gh` per repo per TTL, rather than one per window.
175const DEFAULT_OPEN_PR_TTL: Duration = Duration::from_secs(60);
176
177/// The `--json` fields the daemon requests from `gh pr list`, mirroring the
178/// extension's `PR_JSON_FIELDS` so the forwarded array parses into its
179/// `PullRequest` shape unchanged.
180const OPEN_PR_JSON_FIELDS: &str = "number,title,url,headRefName,baseRefName,isDraft,state,author";
181
182/// The `gh pr list --limit` cap — high enough to list a repo's open PRs in one call
183/// (parity with the extension's `PR_LIST_LIMIT`).
184const OPEN_PR_LIST_LIMIT: &str = "100";
185
186/// The resolved open-PR cache TTL: `OMNI_DEV_DAEMON_OPEN_PR_TTL` (whole seconds)
187/// when valid, else [`DEFAULT_OPEN_PR_TTL`].
188fn open_pr_ttl() -> Duration {
189 crate::daemon::server::duration_secs_from_env(ENV_OPEN_PR_TTL, DEFAULT_OPEN_PR_TTL)
190}
191
192/// Environment override for [`rate_limit_poll_interval`] — the cadence at which
193/// the GitHub rate-limit monitor re-reads `/rate_limit` (whole seconds; a blank,
194/// non-numeric, or `0` value falls back to [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`]).
195const ENV_RATE_LIMIT_POLL_INTERVAL: &str = "OMNI_DEV_DAEMON_RATE_LIMIT_POLL";
196
197/// Default cadence for the GitHub rate-limit monitor (#1375).
198///
199/// A fixed 60 s is fine and simple: querying `/rate_limit` is **exempt** — it
200/// spends nothing against any budget — so unlike the PR poller this cadence has no
201/// budget concern and needs no adaptive backoff. One free `gh` subprocess a minute
202/// keeps `daemon status` current enough to catch a slow drain.
203const DEFAULT_RATE_LIMIT_POLL_INTERVAL: Duration = Duration::from_secs(60);
204
205/// The resolved rate-limit-poll cadence: `OMNI_DEV_DAEMON_RATE_LIMIT_POLL` (whole
206/// seconds) when valid, else [`DEFAULT_RATE_LIMIT_POLL_INTERVAL`].
207fn rate_limit_poll_interval() -> Duration {
208 crate::daemon::server::duration_secs_from_env(
209 ENV_RATE_LIMIT_POLL_INTERVAL,
210 DEFAULT_RATE_LIMIT_POLL_INTERVAL,
211 )
212}
213
214/// A running background menu-refresh task and the token that stops it.
215struct RefreshTask {
216 /// Cancelled by `shutdown` to end the refresh loop.
217 token: CancellationToken,
218 /// The spawned loop, awaited on shutdown so it fully unwinds.
219 handle: JoinHandle<()>,
220}
221
222/// Whether this tick should spend a `gh` call.
223///
224/// The poller wakes far more often than it fetches — waking is a cached snapshot
225/// read, fetching is a subprocess and a network round trip. Two things justify the
226/// call: the watch set **grew** (a target was added, or a branch's upstream moved —
227/// a push, invisible to the change-notify, so only looking finds it), or the
228/// **backoff elapsed** and it is simply time to look again.
229///
230/// A pure *removal* is deliberately not a reason (#1389): a window closing, a
231/// worktree going away, a lease lapsing, or a TTL reap can never change any
232/// **surviving** badge, so `grew` is false and the poll skips — see
233/// [`pr_watch_grew`]. That kills the close-side of every burn scenario.
234///
235/// Pure so the policy is testable directly: from outside, the only evidence of it
236/// is *when* a subprocess runs, which a test cannot pin down without either flaking
237/// or passing for the wrong reason.
238fn pr_should_fetch(grew: bool, since_last_fetch: Option<Duration>, backoff: Duration) -> bool {
239 // `map_or(true, ..)` rather than `is_none_or`: the latter is stable only
240 // since 1.82 and this crate's MSRV is 1.80.
241 grew || since_last_fetch.map_or(true, |elapsed| elapsed >= backoff)
242}
243
244/// Whether `next` holds a watch the poller has not already resolved for its
245/// current upstream — an **addition** (a new (repo, branch) target) or an
246/// **upstream that moved** (a push — the #1344 case that starts the CI run a badge
247/// reports). Either warrants asking GitHub *now*.
248///
249/// A pure removal is never "grew": [`PrWatch`] equality is `(target, upstream_sha)`
250/// only, so a shrunk `next` that is otherwise a subset of `prev` returns `false`
251/// and the poll coalesces (#1389). Head-only moves are excluded by construction —
252/// [`PrWatch`] carries no head — because a local commit GitHub has not seen returns
253/// exactly the cached verdict, and the badge stays correctly stale through
254/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) with no
255/// network call (#1389, fix 3).
256///
257/// Pure so the fetch trigger is testable without driving a live subprocess.
258fn pr_watch_grew(prev: &[PrWatch], next: &[PrWatch]) -> bool {
259 next.iter().any(|w| !prev.contains(w))
260}
261
262/// The next PR-poll delay.
263///
264/// - **Terminal** (`pending` false): double `current` up to [`MAX_PR_POLL_INTERVAL`]
265/// — nothing is expected to change, so this is a slow liveness heartbeat. A failed
266/// poll passes `pending: false`, so a persistent failure backs off here rather
267/// than being retried hard.
268/// - **Pending** (`pending` true): hold `base` (~10 s) for the first
269/// [`PENDING_FAST_WINDOW`] after the watch last moved, then escalate — double
270/// `current` up to [`PENDING_MAX_INTERVAL`] (#1389, fix 5). A single 20-minute CI
271/// run used to pin `base` for its whole duration (360 calls/hour); escalating
272/// *within* pending caps that while a verdict arriving a cadence-tick late stays
273/// invisible in the tray. `since_moved` is time since fresh work was last seen (a
274/// push or an added target), or `None` when nothing has moved yet — treated as
275/// past the fast window so a stale-from-boot pending state does not pin `base`.
276///
277/// A pure function rather than copies inline, because the cadence is only
278/// observable from outside as timing, which a test cannot assert without flaking.
279fn next_pr_poll_delay(
280 current: Duration,
281 base: Duration,
282 pending: bool,
283 since_moved: Option<Duration>,
284) -> Duration {
285 if !pending {
286 return current.saturating_mul(2).min(MAX_PR_POLL_INTERVAL);
287 }
288 match since_moved {
289 // Fresh work: watch it closely at `base` while it is likely to resolve.
290 Some(elapsed) if elapsed < PENDING_FAST_WINDOW => base,
291 // Still pending well after the move (a long CI run, or a zombie suite):
292 // escalate so the cadence stops burning, bounded below the terminal ceiling.
293 _ => current.saturating_mul(2).min(PENDING_MAX_INTERVAL),
294 }
295}
296
297/// Stretches a computed poll delay when the shared GitHub budget is under pressure.
298///
299/// The daemon is the **single** `gh` choke point for every open window, so this is
300/// the one place a machine-wide cap can actually be enforced (#1389, fix 6). When
301/// any tracked resource is at/over
302/// [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT), the cadence is held at
303/// no less than [`BUDGET_THROTTLE_INTERVAL`] so a runaway in this class cannot drain
304/// the budget the whole machine shares — structurally, not just by convention. Below
305/// the threshold (or with no reading yet) the delay is returned unchanged.
306///
307/// Pure so the throttle is testable without a live rate-limit poll.
308fn budget_throttled_delay(delay: Duration, rate_limit: Option<&RateLimitSnapshot>) -> Duration {
309 if rate_limit.is_some_and(RateLimitSnapshot::over_warn) {
310 delay.max(BUDGET_THROTTLE_INTERVAL)
311 } else {
312 delay
313 }
314}
315
316/// Whether the rate-limit poller should emit a WARN this poll: `true` only when a
317/// resource **crosses** the [`WARN_PERCENT`](crate::github_rate_limit::WARN_PERCENT)
318/// threshold upward since the previous reading (or is already over on the first
319/// poll, when `prev` is `None`). Keying on the rising edge means the log fires once
320/// per crossing rather than every poll while usage stays high.
321///
322/// Pure so the policy is testable without driving a live poll.
323fn rate_limit_crossed_warn(prev: Option<&RateLimitSnapshot>, next: &RateLimitSnapshot) -> bool {
324 let over = |res: Option<RateLimitResource>| res.is_some_and(|r| r.over_warn());
325 // Per-resource so a *different* resource crossing (while another recovers) is
326 // still caught — `graphql` dropping below while `core` climbs over would look
327 // unchanged to a whole-snapshot `over_warn` comparison.
328 [
329 (prev.and_then(|p| p.graphql), next.graphql),
330 (prev.and_then(|p| p.core), next.core),
331 (prev.and_then(|p| p.search), next.search),
332 ]
333 .into_iter()
334 .any(|(before, after)| over(after) && !over(before))
335}
336
337/// A running background PR-badge poll task and the token that stops it.
338struct PollerTask {
339 /// Cancelled by `shutdown` to end the poll loop.
340 token: CancellationToken,
341 /// The spawned loop, awaited on shutdown so it fully unwinds.
342 handle: JoinHandle<()>,
343}
344
345/// One thing the PR poller watches: a badge target and the commit its upstream
346/// points at.
347///
348/// The upstream OID is what makes a **push** observable to the poller. A window
349/// opening bumps the registry's change-notify, but nothing notifies the daemon
350/// when you push — so the poller compares this against the previous tick's and
351/// treats an added target or a moved upstream as "go and ask now" (see
352/// [`pr_watch_grew`]). A push moves **only** the upstream (#1344), and it is the
353/// very thing that starts the CI run a badge reports, so the upstream must be here.
354///
355/// The local HEAD is deliberately **not** watched (#1389, fix 3): a local commit
356/// GitHub has not seen would return exactly the cached verdict, so asking wastes a
357/// call, and the badge stays correctly stale through
358/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for) — a local
359/// comparison, no network — until the branch is actually pushed. Equality is thus
360/// `(target, upstream_sha)`, which is exactly the key [`pr_watch_grew`] compares.
361#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
362struct PrWatch {
363 /// The (repo, branch) to resolve a badge for.
364 target: PrTarget,
365 /// That branch's upstream tip, or `None` when it tracks no upstream.
366 upstream_sha: Option<String>,
367}
368
369/// Extracts what the poller watches — the badge targets and their local heads and
370/// upstream tips — from a `tree` snapshot.
371///
372/// Reading them back off the snapshot — rather than walking git again — means the
373/// poller reuses the coalescing [`TreeSnapshotCache`] build instead of adding a
374/// second independent per-worktree git walk, which is the idle-CPU cost #1305 went
375/// out of its way to remove. Only GitHub repos with a branch contribute; the result
376/// is sorted and deduped so N worktrees of one repo on one branch ask once.
377fn pr_watch_from_snapshot(snapshot: &Value) -> Vec<PrWatch> {
378 let mut out = Vec::new();
379 for repo in snapshot
380 .get("repos")
381 .and_then(Value::as_array)
382 .into_iter()
383 .flatten()
384 {
385 // The zero-`gh` guarantee (#1376): a repo the user has not enabled
386 // contributes no watch, so the poll's `gh api graphql` never mentions it.
387 // The snapshot this reads is already `stamp_polling`-stamped, so this one
388 // check is the single filter point — default-off means an absent flag skips.
389 if repo.get("polling_enabled").and_then(Value::as_bool) != Some(true) {
390 continue;
391 }
392 let Some(github) = repo.get("github") else {
393 continue;
394 };
395 let (Some(owner), Some(name)) = (
396 github.get("owner").and_then(Value::as_str),
397 github.get("name").and_then(Value::as_str),
398 ) else {
399 continue;
400 };
401 for wt in repo
402 .get("worktrees")
403 .and_then(Value::as_array)
404 .into_iter()
405 .flatten()
406 {
407 if let Some(branch) = wt.get("branch").and_then(Value::as_str) {
408 out.push(PrWatch {
409 upstream_sha: wt
410 .get("upstream_sha")
411 .and_then(Value::as_str)
412 .map(str::to_string),
413 target: PrTarget {
414 owner: owner.to_string(),
415 name: name.to_string(),
416 branch: branch.to_string(),
417 },
418 });
419 }
420 }
421 }
422 out.sort();
423 out.dedup();
424 out
425}
426
427/// The (repo, branch) pairs to resolve badges for — [`pr_watch_from_snapshot`]
428/// without the heads.
429#[cfg(test)]
430fn pr_targets_from_snapshot(snapshot: &Value) -> Vec<PrTarget> {
431 pr_watch_from_snapshot(snapshot)
432 .into_iter()
433 .map(|w| w.target)
434 .collect()
435}
436
437/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
438pub struct WorktreesService {
439 /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
440 /// the background menu-refresh task can read it off the main thread.
441 registry: Arc<WorktreesRegistry>,
442 /// The most recent tray menu snapshot, recomputed off the main thread by
443 /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
444 /// of this so it never blocks on git enrichment. `None` until the first
445 /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
446 /// which case `menu()` falls back to a one-off inline compute.
447 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
448 /// The background refresh task, once started (`None` in tests / no runtime).
449 refresh: Mutex<Option<RefreshTask>>,
450 /// PR badges resolved by the background poller and read by the tree snapshot
451 /// build (#1337). Behind an `Arc` so the poll task and the snapshot builder
452 /// share the one cache. Empty until the first poll lands — and always empty
453 /// when no poller runs (unit tests), in which case the tree simply carries no
454 /// `pr` field, exactly as a pre-#1337 daemon did.
455 pr_cache: Arc<PrStatusCache>,
456 /// The background PR-badge poll task, once started (`None` in tests / no
457 /// runtime).
458 poller: Mutex<Option<PollerTask>>,
459 /// The GitHub API rate-limit snapshot the [`rate-limit poller`] writes and the
460 /// tray menu build / built-in `status` op read (#1375). Behind an `Arc` so the
461 /// poll task, the tray refresh, and the daemon's registry share the one cache.
462 /// Empty until the first poll lands; the daemon hands a clone to the registry
463 /// so `status` can report machine-wide GitHub budget usage.
464 ///
465 /// [`rate-limit poller`]: Self::start_rate_limit_poller
466 rate_limit_cache: Arc<RateLimitCache>,
467 /// The background rate-limit poll task, once started (`None` in tests / no
468 /// runtime).
469 rate_limit_poller: Mutex<Option<PollerTask>>,
470 /// The shared, coalescing tree-snapshot cache every `subscribe` stream reads
471 /// through, so N open windows perform **one** `build_tree` per tick instead
472 /// of N (#1303). Behind an `Arc` so each stream holds a cheap handle to the
473 /// one cache. The one-shot `tree` op deliberately bypasses it and computes
474 /// fresh (it is a rare manual refresh, not part of the per-tick fan-out).
475 tree_cache: Arc<TreeSnapshotCache>,
476 /// Serializes [`remove_worktree`] across concurrent `close` executes (#1359).
477 ///
478 /// The extension fans a multi-select delete out into one `close` op per
479 /// target, so two executes can reach the prune at once. Their heartbeat waits
480 /// overlap freely — that is the point — but the prunes themselves should not:
481 /// each op enumerates the repo's worktrees ([`worktree_name_for_path`]) and
482 /// then prunes an entry out of that same `.git/worktrees`, so concurrent ops
483 /// read a directory a sibling is midway through removing from, and `git2`
484 /// promises nothing about that. Precautionary rather than a fix for an
485 /// observed corruption — the window is narrow enough that it has not been
486 /// reproduced — but serializing costs nothing measurable (the prune is a
487 /// directory delete; the wait it follows is seconds) and keeps the fan-out
488 /// safe at the source rather than relying on every caller to stay sequential.
489 ///
490 /// A `tokio` mutex rather than a `std` one: it is held across the
491 /// `spawn_blocking` join, which is an `.await`.
492 prune_lock: tokio::sync::Mutex<()>,
493 /// Where the per-repo PR-poll enable set is persisted (#1376), so a user's
494 /// choice survives a daemon restart. `None` disables persistence entirely —
495 /// the default from [`new`](Self::new), which keeps the bare service cheap
496 /// and I/O-free for unit tests; the daemon wires it via
497 /// [`load_polling_prefs`](Self::load_polling_prefs) at startup. Behind a
498 /// `std::Mutex` only so `load_polling_prefs` can set it on `&self`; read
499 /// briefly and never held across an `.await`.
500 polling_prefs_path: Mutex<Option<PathBuf>>,
501 /// Where the resolved PR-badge cache is persisted (#1389, fix 4), so badges
502 /// survive a daemon restart and the poller can skip its immediate re-poll when
503 /// they are still fresh. `None` disables persistence — the default from
504 /// [`new`](Self::new), keeping the bare service I/O-free for unit tests; the
505 /// daemon wires it via [`load_pr_cache`](Self::load_pr_cache) at startup. Same
506 /// `std::Mutex`-only-to-set-on-`&self` role as [`Self::polling_prefs_path`].
507 pr_cache_path: Mutex<Option<PathBuf>>,
508 /// The warm-start state restored from the persisted cache (#1389, fix 4),
509 /// taken by [`start_pr_poller_with`](Self::start_pr_poller_with) when the loop
510 /// spawns. `None` on a cold start (no file, or persistence disabled), in which
511 /// case the poller does its normal first fetch.
512 pr_warm_start: Mutex<Option<PrWarmStart>>,
513 /// Shared TTL cache of `gh pr list` results per repo, backing the daemon-served
514 /// `open-prs` op (#1389, fix 7) so N windows' "Open Pull Request…" lookups
515 /// dedupe to one counted `gh` per repo instead of one per window. Behind an
516 /// `Arc` for parity with the other caches.
517 open_pr_cache: Arc<OpenPrCache>,
518}
519
520impl WorktreesService {
521 /// Creates the service with an empty registry. Cheap — no I/O and no task;
522 /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
523 /// off-thread menu caching, while tests use the bare service (menu computed
524 /// inline on demand).
525 #[must_use]
526 pub fn new() -> Self {
527 let registry = Arc::new(WorktreesRegistry::new());
528 let pr_cache = Arc::new(PrStatusCache::new());
529 Self {
530 registry: registry.clone(),
531 menu_cache: Arc::new(Mutex::new(None)),
532 refresh: Mutex::new(None),
533 pr_cache: pr_cache.clone(),
534 poller: Mutex::new(None),
535 rate_limit_cache: Arc::new(RateLimitCache::new()),
536 rate_limit_poller: Mutex::new(None),
537 tree_cache: Arc::new(TreeSnapshotCache::new(registry, pr_cache)),
538 prune_lock: tokio::sync::Mutex::new(()),
539 polling_prefs_path: Mutex::new(None),
540 pr_cache_path: Mutex::new(None),
541 pr_warm_start: Mutex::new(None),
542 open_pr_cache: Arc::new(OpenPrCache::new(open_pr_ttl())),
543 }
544 }
545
546 /// Seeds the per-repo PR-poll enable set from the persisted `0600` prefs file
547 /// and remembers `path` so later [`set-polling`](Self::handle) changes persist
548 /// back to it (#1376). Called once by the daemon at startup, before any window
549 /// subscribes — so [`seed_polling`](WorktreesRegistry::seed_polling) needs no
550 /// bump. Best-effort throughout: a missing file is the first-run default (no
551 /// repos enabled), and a corrupt/unreadable one is logged and treated as
552 /// empty rather than wedging the service — the user simply re-enables. The
553 /// path is stored regardless, so the next change rewrites a clean file.
554 pub fn load_polling_prefs(&self, path: PathBuf) {
555 match std::fs::read(&path) {
556 Ok(bytes) => match serde_json::from_slice::<PollingPrefs>(&bytes) {
557 Ok(prefs) => self
558 .registry
559 .seed_polling(prefs.enabled.into_iter().map(|l| (l.repo, l.expires_at))),
560 Err(err) => tracing::warn!(
561 "ignoring unreadable worktrees polling prefs at {}: {err:#}",
562 path.display()
563 ),
564 },
565 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
566 Err(err) => tracing::warn!(
567 "could not read worktrees polling prefs at {}: {err:#}",
568 path.display()
569 ),
570 }
571 *self
572 .polling_prefs_path
573 .lock()
574 .unwrap_or_else(PoisonError::into_inner) = Some(path);
575 }
576
577 /// Writes the current enable set to the `0600` prefs file, if persistence is
578 /// configured ([`load_polling_prefs`](Self::load_polling_prefs) set a path).
579 /// Best-effort: a write failure is logged at WARN and swallowed, since the
580 /// in-memory set is authoritative for the running daemon — the user's toggle
581 /// still took effect, it just would not survive a restart. A no-op (returns
582 /// early) in unit tests, which never configure a path.
583 fn persist_polling_prefs(&self) {
584 let Some(path) = self
585 .polling_prefs_path
586 .lock()
587 .unwrap_or_else(PoisonError::into_inner)
588 .clone()
589 else {
590 return;
591 };
592 let prefs = PollingPrefs {
593 enabled: self
594 .registry
595 .polling_snapshot()
596 .into_iter()
597 .map(|(repo, expires_at)| PollingLease { repo, expires_at })
598 .collect(),
599 };
600 if let Err(err) = write_polling_prefs(&path, &prefs) {
601 tracing::warn!(
602 "could not persist worktrees polling prefs to {}: {err:#}",
603 path.display()
604 );
605 }
606 }
607
608 /// Seeds the resolved PR-badge cache from the persisted `0600` file and
609 /// remembers `path` so each poll persists back to it (#1389, fix 4). Called
610 /// once by the daemon at startup, before any window subscribes and before the
611 /// poller spawns, so restored badges render on the first tree snapshot and the
612 /// poller can skip its immediate re-poll for verdicts still fresh.
613 ///
614 /// Best-effort throughout (the [`load_polling_prefs`](Self::load_polling_prefs)
615 /// contract): a missing file is the cold-start default, and a corrupt/unreadable
616 /// one is logged and treated as empty — the poller simply re-resolves. The path
617 /// is stored regardless, so the next poll rewrites a clean file. Restores both
618 /// the badges (into [`pr_cache`](Self::pr_cache)) and the
619 /// [`PrWarmStart`](PrWarmStart) the poller reads at spawn.
620 pub fn load_pr_cache(&self, path: PathBuf) {
621 match std::fs::read(&path) {
622 Ok(bytes) => match serde_json::from_slice::<PrCachePrefs>(&bytes) {
623 Ok(prefs) => {
624 self.pr_cache.seed(
625 prefs
626 .entries
627 .into_iter()
628 .map(|e| (e.target, e.resolution.into_resolution())),
629 );
630 // A warm start needs both a watch set to compare against and a
631 // poll time to age it; without `polled_at` the file is too old a
632 // shape to trust, so treat it as a cold start (badges still
633 // render, the poller just re-polls immediately).
634 if let Some(polled_at) = prefs.polled_at {
635 let watched = prefs
636 .watched
637 .into_iter()
638 .map(|w| PrWatch {
639 target: w.target,
640 upstream_sha: w.upstream_sha,
641 })
642 .collect();
643 *self
644 .pr_warm_start
645 .lock()
646 .unwrap_or_else(PoisonError::into_inner) =
647 Some(PrWarmStart { watched, polled_at });
648 }
649 }
650 // Bind the path so it is formatted whenever the branch runs — not
651 // only when a WARN subscriber is installed — so coverage sees it
652 // (the `let summary = …` pattern the rate-limit warn uses).
653 Err(err) => {
654 let at = path.display();
655 tracing::warn!("ignoring unreadable worktrees PR cache at {at}: {err:#}");
656 }
657 },
658 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
659 Err(err) => {
660 let at = path.display();
661 tracing::warn!("could not read worktrees PR cache at {at}: {err:#}");
662 }
663 }
664 *self
665 .pr_cache_path
666 .lock()
667 .unwrap_or_else(PoisonError::into_inner) = Some(path);
668 }
669
670 /// Resolves a repo's open pull requests for the `open-prs` op (#1389, fix 7),
671 /// served from the shared TTL cache when fresh, else **one** counted `gh pr
672 /// list`. The `gh` runs on a blocking thread (never an async worker), routed
673 /// through the #1387-counted [`run_gh`](crate::github_metrics::run_gh) choke
674 /// point so the call is still counted exactly once — the constraint the whole
675 /// of #1389 preserves. The result is forwarded to the extension verbatim.
676 async fn open_prs(&self, owner: &str, name: &str) -> Result<Vec<Value>> {
677 // The `gh` binary is resolved once here (the env read is process-stable),
678 // then handed to the seam below — the poller's "bin as a param" pattern, so
679 // a test injects a stub without mutating the process environment (#1030).
680 self.open_prs_with(owner, name, crate::pr_status::resolve_gh_binary())
681 .await
682 }
683
684 /// [`open_prs`](Self::open_prs) with an explicit `gh` binary, so a test drives
685 /// the cache against a stub without touching the environment.
686 async fn open_prs_with(&self, owner: &str, name: &str, bin: PathBuf) -> Result<Vec<Value>> {
687 let key = format!("{owner}/{name}");
688 if let Some(prs) = self.open_pr_cache.fresh(&key) {
689 return Ok(prs);
690 }
691 let slug = key.clone();
692 let prs = tokio::task::spawn_blocking(move || open_pr_list(&bin, &slug))
693 .await
694 .unwrap_or_else(|err| Err(anyhow!("blocking open-prs task failed: {err}")))?;
695 self.open_pr_cache.store(key, prs.clone());
696 Ok(prs)
697 }
698
699 /// A handle to the GitHub rate-limit snapshot cache (#1375), so the daemon can
700 /// share it with the [`ServiceRegistry`](crate::daemon::registry::ServiceRegistry)
701 /// for the built-in `status` op to read.
702 #[must_use]
703 pub fn rate_limit_cache(&self) -> Arc<RateLimitCache> {
704 self.rate_limit_cache.clone()
705 }
706
707 /// Starts the background task that recomputes the tray menu snapshot every
708 /// [`menu_refresh_interval`] **off the main thread** — git enrichment is
709 /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
710 /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
711 /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
712 /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
713 /// keep computing the menu inline.
714 pub fn start_menu_refresh(&self) {
715 if tokio::runtime::Handle::try_current().is_err() {
716 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
717 return;
718 }
719 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
720 if guard.is_some() {
721 return;
722 }
723 let token = CancellationToken::new();
724 let loop_token = token.clone();
725 let registry = self.registry.clone();
726 let cache = self.menu_cache.clone();
727 let rate_limit_cache = self.rate_limit_cache.clone();
728 // Resolved once at spawn: the interval is process-stable env config, and
729 // re-reading it every loop would be wasted work.
730 let interval = menu_refresh_interval();
731 let handle = tokio::spawn(async move {
732 loop {
733 // Snapshot the registry (a cheap lock), then build the menu —
734 // which opens repos and parses git config — on a blocking thread,
735 // never on this async worker or the tray's main thread.
736 let entries = registry.list();
737 // The rate-limit reading (a cheap lock, `Copy`) prepends a status
738 // line; read it here and hand it to the blocking build (#1375).
739 let rate_limit = rate_limit_cache.get();
740 if let Ok(items) = tokio::task::spawn_blocking(move || {
741 menu_items_for(&entries, rate_limit.as_ref())
742 })
743 .await
744 {
745 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
746 }
747 tokio::select! {
748 () = loop_token.cancelled() => break,
749 () = tokio::time::sleep(interval) => {}
750 }
751 }
752 });
753 *guard = Some(RefreshTask { token, handle });
754 }
755
756 /// Starts the background task that keeps PR check badges fresh (#1337).
757 ///
758 /// This is the half of the badge nothing else can do. Badges used to be
759 /// resolved extension-side on repo-expand, so they were recomputed only when a
760 /// repo node's children were rebuilt — and the streamed snapshot carries
761 /// worktree topology, not CI. While CI ran and no window opened or closed,
762 /// nothing re-asked GitHub and a badge stayed wrong indefinitely.
763 ///
764 /// The loop resolves **every** (repo, branch) pair in one `gh api graphql` call
765 /// (cost 1, independent of repo/worktree/window count), writes the cache the
766 /// tree snapshot reads, and bumps the registry's change-notify **only when a
767 /// verdict actually moved** — so the server's diff pushes to every open window
768 /// exactly when CI state changes, and never otherwise.
769 ///
770 /// Cadence adapts: [`pr_poll_interval`] (~10 s) while a badge is pending and
771 /// fresh, escalating to [`PENDING_MAX_INTERVAL`] once a pending phase runs long
772 /// (#1389, fix 5) and doubling to [`MAX_PR_POLL_INTERVAL`] once everything is
773 /// terminal; it polls nothing at all while no window is registered.
774 ///
775 /// It spends a `gh` call only when the watch set **grows** — a target added or
776 /// an upstream pushed (#1389, fixes 1/3) — or the backoff elapses; a pure
777 /// removal (window close, VS Code/daemon shutdown, TTL reap, lease lapse) never
778 /// fetches. A change-notify storm is debounced ([`pr_debounce_interval`],
779 /// #1389 fix 2) into one fetch, restored badges survive a restart (#1389 fix
780 /// 4), and the cadence is capped when the shared GitHub budget is strained
781 /// (#1389 fix 6).
782 ///
783 /// Idempotent, and a no-op outside a tokio runtime (mirroring
784 /// [`start_menu_refresh`](Self::start_menu_refresh) and the Snowflake keep-alive
785 /// heartbeat), so unit tests build a bare service that never spawns `gh`.
786 pub fn start_pr_poller(&self) {
787 // Resolved once at spawn: process-stable env config (the menu-refresh
788 // precedent), never re-read per poll.
789 self.start_pr_poller_with(
790 pr_poll_interval(),
791 pr_debounce_interval(),
792 crate::pr_status::resolve_gh_binary(),
793 );
794 }
795
796 /// [`start_pr_poller`](Self::start_pr_poller) with an explicit cadence,
797 /// debounce settle window, and `gh` binary, so tests drive the loop at
798 /// millisecond speed against a stub **without mutating the process
799 /// environment** — one global env var cannot serve two parallel tests pointing
800 /// at different fakes. Mirrors the [`TreeSnapshotCache::with_ttl`] seam and the
801 /// Snowflake heartbeat's "interval via config, not env" rule.
802 ///
803 /// Reads the rate-limit cache, the persistence path, and the warm-start state
804 /// off `self` at spawn (all `#1389` inputs), so its signature stays close to
805 /// the original two-cadence seam.
806 fn start_pr_poller_with(&self, base: Duration, debounce: Duration, gh_bin: PathBuf) {
807 if tokio::runtime::Handle::try_current().is_err() {
808 tracing::debug!("no tokio runtime; worktrees PR poller not started");
809 return;
810 }
811 let mut guard = self.poller.lock().unwrap_or_else(PoisonError::into_inner);
812 if guard.is_some() {
813 return;
814 }
815 let token = CancellationToken::new();
816 let loop_token = token.clone();
817 let registry = self.registry.clone();
818 let tree_cache = self.tree_cache.clone();
819 let pr_cache = self.pr_cache.clone();
820 // The shared budget reading (#1389, fix 6) and the `0600` persistence path +
821 // restored warm start (#1389, fix 4). The warm start is *taken* — it seeds
822 // the loop once and must not be reused by a later restart of the poller.
823 let rate_limit_cache = self.rate_limit_cache.clone();
824 let pr_cache_path = self
825 .pr_cache_path
826 .lock()
827 .unwrap_or_else(PoisonError::into_inner)
828 .clone();
829 let warm_start = self
830 .pr_warm_start
831 .lock()
832 .unwrap_or_else(PoisonError::into_inner)
833 .take();
834 // Captured here, before the task's first sleep, so a window that registers
835 // while the loop is starting still wakes it rather than being missed.
836 let mut changes = self.registry.subscribe_changes();
837 let handle = tokio::spawn(async move {
838 // Two independent cadences. The loop *wakes* every `base` — cheap: a read
839 // of the coalescing snapshot cache, no subprocess, no network. It only
840 // *asks GitHub* when there is reason to: the watch set grew (a target
841 // added, or an upstream pushed), or the backoff has elapsed.
842 //
843 // They have to be separate because the two things that should trigger a
844 // fetch arrive by different routes. A window opening bumps the registry's
845 // change-notify, but **a push does not** — nothing in the daemon is
846 // notified when you `git push`. The only way to notice is to look, so the
847 // loop looks often and cheaply, and pays only when something grew.
848 let mut backoff = base;
849 // Warm start (#1389, fix 4): resume what the previous daemon last
850 // resolved and when, so a restart within the backoff window skips the
851 // immediate re-poll for verdicts already restored into `pr_cache`.
852 // `last_poll` is reconstructed as an `Instant` that many seconds ago; a
853 // reboot (monotonic epoch reset) or a future timestamp collapses to
854 // "never polled", which just re-polls — the safe direction.
855 let (mut watched, mut last_poll): (Option<Vec<PrWatch>>, Option<Instant>) =
856 match warm_start {
857 Some(ws) => {
858 let elapsed = (Utc::now() - ws.polled_at)
859 .to_std()
860 .unwrap_or(Duration::ZERO);
861 (Some(ws.watched), Instant::now().checked_sub(elapsed))
862 }
863 None => (None, None),
864 };
865 // When fresh work (a push or an added target) was last seen, so the
866 // pending cadence can escalate once it goes quiet (#1389, fix 5).
867 let mut moved_at: Option<Instant> = None;
868 'poll: loop {
869 // Wait first: at startup no window has registered yet, and the
870 // first snapshot would be empty anyway.
871 tokio::select! {
872 () = loop_token.cancelled() => break,
873 () = tokio::time::sleep(base) => {}
874 // A window opened or closed — look now rather than at the next
875 // tick, but debounce first.
876 result = changes.changed() => {
877 // Unreachable today: this task owns an `Arc` of the registry
878 // that holds the sender, so it cannot be dropped while we are
879 // here. Kept anyway because the alternative is worse — a
880 // closed channel makes `changed()` return `Ready` forever, so
881 // ignoring the error would spin this loop at full speed,
882 // re-snapshotting and re-running `gh` every iteration.
883 if result.is_err() {
884 break;
885 }
886 // Debounce (#1389, fix 2): a VS Code restart unregisters then
887 // re-registers its windows one-by-one over several seconds,
888 // each bump waking us; a daemon restart re-registers the same
889 // way. Wait for `debounce` of quiet before snapshotting so the
890 // whole storm collapses to **one** fetch on the final watch
891 // set. Bounded by an overall deadline so a steady drip of
892 // changes cannot postpone the poll forever.
893 let overall_deadline = Instant::now() + debounce.saturating_mul(4);
894 loop {
895 tokio::select! {
896 () = loop_token.cancelled() => break 'poll,
897 () = tokio::time::sleep(debounce) => break,
898 r = changes.changed() => {
899 if r.is_err() {
900 break 'poll;
901 }
902 if Instant::now() >= overall_deadline {
903 break;
904 }
905 }
906 }
907 }
908 }
909 }
910 // Off the coalescing snapshot cache, so this reuses the tick's
911 // `build_tree` rather than walking git a second time.
912 let snapshot = tree_cache.snapshot().await;
913 let watch = pr_watch_from_snapshot(&snapshot);
914 if watch.is_empty() {
915 // No windows, or nothing on GitHub: ask nothing, and forget any
916 // backoff so the next tree starts fresh. But **keep** `watched`
917 // (#1389, fix 4): a VS Code restart momentarily empties the watch
918 // mid-storm, and nulling it here would make the re-registered set
919 // look brand-new and re-fetch. A genuinely gone tree simply has
920 // nothing to compare against on the next non-empty tick.
921 backoff = base;
922 last_poll = None;
923 moved_at = None;
924 continue;
925 }
926 // Did the watched set **grow** (an addition, or an upstream pushed)?
927 // A pure removal is not a reason to fetch (#1389, fix 1); a local
928 // commit is not either (#1389, fix 3, `PrWatch` carries no head).
929 let grew = pr_watch_grew(watched.as_deref().unwrap_or(&[]), &watch);
930 // Prune verdicts for targets that vanished, so a closed worktree's
931 // badge does not linger in the cache (#1389, fix 1) — local, no
932 // network, and correct whether or not this tick goes on to fetch.
933 let keep: HashSet<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
934 pr_cache.retain_targets(&keep);
935 // Budget-aware cap (#1389, fix 6): the daemon is the single `gh`
936 // choke point, so throttling here is the one place a machine-wide cap
937 // works. Over WARN_PERCENT, hold the stretched cadence *and* ignore an
938 // immediate `grew`, so no runaway in this class can drain the shared
939 // budget — structurally, not by convention.
940 let rate_limit = rate_limit_cache.get();
941 let over_budget = rate_limit.is_some_and(|s| s.over_warn());
942 let effective_backoff = budget_throttled_delay(backoff, rate_limit.as_ref());
943 let trigger = grew && !over_budget;
944 if !pr_should_fetch(trigger, last_poll.map(|at| at.elapsed()), effective_backoff) {
945 // Not fetching this tick. Advance `watched` only for a pure shrink
946 // or a quiet identical tick — an addition/push (`grew`) must stay
947 // unresolved so it is still fetched once the cadence or budget
948 // allows, rather than being silently consumed here.
949 if !grew {
950 watched = Some(watch);
951 }
952 continue;
953 }
954 if grew {
955 // Fresh work: watch it closely and restart the escalation clock
956 // rather than serving out a backoff earned while it was quiet.
957 backoff = base;
958 moved_at = Some(Instant::now());
959 }
960 let targets: Vec<PrTarget> = watch.iter().map(|w| w.target.clone()).collect();
961 // `gh` is a blocking subprocess: never on an async worker. A join
962 // failure (the task panicked, or the runtime is going down) folds
963 // into the same error channel as a `gh` failure — both mean "no
964 // badges this round", and neither deserves its own handling.
965 let bin = gh_bin.clone();
966 let resolved = tokio::task::spawn_blocking(move || {
967 crate::pr_status::resolve_with_budget(&bin, &targets)
968 })
969 .await
970 .unwrap_or_else(|err| Err(anyhow!("blocking poll task failed: {err}")));
971 // Best-effort decoration: a missing/unauthenticated `gh`, a network
972 // blip, or a rate limit must never sink the tree. A failed poll
973 // leaves the last good resolutions in place — badges *and* explicit
974 // negatives, and it mints no new negatives (#1370) — rather than
975 // blanking every row, and is not "pending", so it backs off rather
976 // than hammers.
977 let (pending, resolved_ok) = match resolved {
978 Ok((resolutions, budget)) => {
979 // Fold the free budget reading this poll carried into the
980 // shared cache (#1389, fix 8): the graphql figure stays fresh
981 // whenever polling is active, which lets the standalone
982 // `/rate_limit` poller idle (fix 8b), and the poll's `cost`
983 // reveals the real per-call point price.
984 if let Some(b) = budget {
985 tracing::debug!(
986 "PR poll cost {} point(s); graphql {}/{} used, {} remaining",
987 b.cost,
988 b.used,
989 b.limit,
990 b.remaining
991 );
992 rate_limit_cache.observe_graphql(RateLimitResource::new(
993 b.used,
994 b.limit,
995 b.remaining,
996 b.reset,
997 ));
998 }
999 // Bump only on a real change, or the server's diff-and-drop
1000 // is defeated and every window re-renders on every poll.
1001 if pr_cache.replace(resolutions) {
1002 registry.bump();
1003 }
1004 (pr_cache.any_pending(), true)
1005 }
1006 Err(err) => {
1007 tracing::debug!("PR badge poll failed: {err:#}");
1008 (false, false)
1009 }
1010 };
1011 last_poll = Some(Instant::now());
1012 // Record what this verdict was about, so the *next* tick can tell a
1013 // genuine change from a quiet tree.
1014 watched = Some(watch);
1015 let since_moved = moved_at.map(|at| at.elapsed());
1016 backoff = next_pr_poll_delay(backoff, base, pending, since_moved);
1017 // Persist the fresh verdicts (#1389, fix 4) so the next restart
1018 // serves them and can skip its immediate re-poll. Only on a real
1019 // resolution — a failed poll must not advance the persisted poll time
1020 // past the last *good* one. Best-effort; a write failure costs at
1021 // most one extra poll after the next restart.
1022 if resolved_ok {
1023 if let Some(path) = &pr_cache_path {
1024 persist_pr_cache(
1025 path,
1026 &pr_cache,
1027 watched.as_deref().unwrap_or(&[]),
1028 Utc::now(),
1029 );
1030 }
1031 }
1032 }
1033 });
1034 *guard = Some(PollerTask { token, handle });
1035 }
1036
1037 /// Starts the background task that keeps the GitHub API rate-limit reading
1038 /// fresh (#1375).
1039 ///
1040 /// The daemon's PR-badge poller shells out to `gh`, spending the same GitHub
1041 /// budget as every other tool sharing the user's token; when that drains, `gh`
1042 /// rate-limits machine-wide with no warning until commands start failing. This
1043 /// loop polls `gh api rate_limit` — an endpoint GitHub documents (and this
1044 /// project verified) as **exempt**, spending nothing against any budget — so
1045 /// `daemon status`, the JSON payload, and the tray can show the used-percentage
1046 /// *trend* and warn before exhaustion, at zero cost to the budget watched.
1047 ///
1048 /// A plain fixed cadence ([`rate_limit_poll_interval`], ~60 s): no adaptive
1049 /// backoff and no window-gating, because the endpoint is free and a current
1050 /// reading is wanted whenever an operator checks `status`. Idempotent, and a
1051 /// no-op outside a tokio runtime (mirroring [`start_pr_poller`](Self::start_pr_poller)
1052 /// and [`start_menu_refresh`](Self::start_menu_refresh)), so unit tests build a
1053 /// bare service that never spawns `gh`.
1054 pub fn start_rate_limit_poller(&self) {
1055 // Both resolved once at spawn: process-stable env config, never re-read per
1056 // poll (the PR-poller precedent).
1057 self.start_rate_limit_poller_with(
1058 rate_limit_poll_interval(),
1059 crate::pr_status::resolve_gh_binary(),
1060 );
1061 }
1062
1063 /// [`start_rate_limit_poller`](Self::start_rate_limit_poller) with an explicit
1064 /// cadence and `gh` binary, so tests drive the loop at millisecond speed
1065 /// against a stub **without mutating the process environment** (the
1066 /// [`start_pr_poller_with`](Self::start_pr_poller_with) seam).
1067 fn start_rate_limit_poller_with(&self, interval: Duration, gh_bin: PathBuf) {
1068 if tokio::runtime::Handle::try_current().is_err() {
1069 tracing::debug!("no tokio runtime; worktrees rate-limit poller not started");
1070 return;
1071 }
1072 let mut guard = self
1073 .rate_limit_poller
1074 .lock()
1075 .unwrap_or_else(PoisonError::into_inner);
1076 if guard.is_some() {
1077 return;
1078 }
1079 let token = CancellationToken::new();
1080 let loop_token = token.clone();
1081 let cache = self.rate_limit_cache.clone();
1082 let registry = self.registry.clone();
1083 let handle = tokio::spawn(async move {
1084 // Remembers the previous reading so a WARN fires only on the *rising*
1085 // edge across the threshold, not every poll while usage stays high.
1086 let mut prev: Option<RateLimitSnapshot> = None;
1087 loop {
1088 // Gate the poll on activity (#1389, fix 8b): a fully-idle daemon —
1089 // no window registered and no polling lease active — has nothing to
1090 // watch, so it spends no `/rate_limit` subprocess and lets the last
1091 // reading stand. The read is free against the budget, but the
1092 // wakeups are not; and while polling *is* active the graphql figure
1093 // stays fresh from every PR poll's folded-in budget (fix 8a), so the
1094 // standalone poll is only topping up `core`/`search`.
1095 if !registry.list().is_empty() || !registry.polling_snapshot().is_empty() {
1096 // Poll first, so `status` has a reading soon after a window
1097 // appears rather than one interval later. `gh` is a blocking
1098 // subprocess: never on an async worker. A join failure folds into
1099 // the same channel as a `gh` failure — both mean "no fresh
1100 // reading this round".
1101 let bin = gh_bin.clone();
1102 let resolved =
1103 tokio::task::spawn_blocking(move || resolve_rate_limit_with(&bin))
1104 .await
1105 .unwrap_or_else(|err| {
1106 Err(anyhow!("blocking rate-limit poll task failed: {err}"))
1107 });
1108 match resolved {
1109 Ok(snap) => {
1110 if rate_limit_crossed_warn(prev.as_ref(), &snap) {
1111 // Bound to a local (rather than inlined into the
1112 // macro) so it is computed whenever the branch is
1113 // taken, not only when a WARN-level subscriber is
1114 // installed — `tracing` skips evaluating macro args
1115 // otherwise.
1116 let summary = snap.summary_line();
1117 tracing::warn!(
1118 "GitHub API rate limit high: {summary} (querying \
1119 /rate_limit is free; the daemon's gh usage is not)"
1120 );
1121 }
1122 // Update the cache only; deliberately no `registry.bump()`
1123 // — the rate limit is not tree topology, and bumping would
1124 // re-push an unchanged tree to every window. The tray
1125 // re-polls `menu()` at ~1 Hz and `status` reads on demand.
1126 cache.replace(snap);
1127 prev = Some(snap);
1128 }
1129 // Best-effort decoration: a missing/unauthenticated `gh` or a
1130 // network blip leaves the last good reading in place rather
1131 // than blanking the line, and never affects the budget (the
1132 // read is free).
1133 Err(err) => tracing::debug!("GitHub rate-limit poll failed: {err:#}"),
1134 }
1135 }
1136 tokio::select! {
1137 () = loop_token.cancelled() => break,
1138 () = tokio::time::sleep(interval) => {}
1139 }
1140 }
1141 });
1142 *guard = Some(PollerTask { token, handle });
1143 }
1144
1145 /// Handles the `close` op: close a worktree's window and, for a **linked**
1146 /// worktree, delete it. The flow has two phases keyed off `confirmed`:
1147 ///
1148 /// - **Phase 1** (`remove:true`, `confirmed:false`) — a pure, side-effect-free
1149 /// [`git_safety`] check returning the risks of deleting, so the extension can
1150 /// show a modal confirm only when something would actually be lost.
1151 /// - **Phase 2** (`confirmed:true`, or any `remove:false`) — execute: signal
1152 /// the owning window(s) to close, then (for `remove:true`) `git2`-prune the
1153 /// worktree. The main working tree is refused defensively.
1154 ///
1155 /// Cross-window signalling (another window has the target open) is a
1156 /// fast-follow: this core handles the **no-window** and **self-close**
1157 /// (`requester_key == target_key`) cases, and errors clearly when another
1158 /// window owns the target so the destructive path is never taken blind.
1159 async fn close(&self, req: CloseRequest) -> Result<Value> {
1160 // Which live windows currently have the target open. The canonical-path
1161 // compare is disk I/O, so run it (with the safety check below) on a
1162 // blocking thread, never under the registry lock or on the async worker.
1163 let entries = self.registry.list();
1164 let scan_path = req.path.clone();
1165 let open_windows =
1166 tokio::task::spawn_blocking(move || windows_with_path(&entries, &scan_path))
1167 .await
1168 .unwrap_or_default();
1169 let open = !open_windows.is_empty();
1170 let window_key = open_windows.first().map(|(k, _)| k.clone());
1171 let window_folder_count = open_windows.first().map_or(0, |(_, c)| *c);
1172
1173 // Phase 1: the safety check runs only for a delete request awaiting
1174 // confirmation. A "Close Window" (remove:false) never inspects git and
1175 // has nothing to confirm, so it skips straight to execute.
1176 if req.remove && !req.confirmed {
1177 let path = req.path.clone();
1178 let git = tokio::task::spawn_blocking(move || git_safety(&path))
1179 .await
1180 .map_err(|e| anyhow!("safety check task panicked: {e}"))
1181 .and_then(|inner| inner)
1182 .map_err(|err| log_close_error(&req.path, "safety check", err))?;
1183 // Make the phase-1 verdict auditable in `omni-dev daemon logs`
1184 // (#1364): the target, the owning window key (if any), whether a
1185 // window has it open, and the deletability verdict — with the
1186 // blocking risk kinds that force a confirm dialog, so a later "why did
1187 // the close prompt/refuse?" is answerable from the log alone.
1188 log_safety_check(&req.path, window_key.as_deref(), &git, open);
1189 return Ok(serde_json::to_value(SafetyReport {
1190 removable: git.removable,
1191 is_main: git.is_main,
1192 open,
1193 window_key,
1194 window_folder_count,
1195 risks: git.risks,
1196 info: git.info,
1197 })
1198 .unwrap_or_else(|_| json!({})));
1199 }
1200
1201 // Phase 2: execute. Signal every owning window *other than the
1202 // requester* (which closes itself on our `ok:true` reply, avoiding the
1203 // ext-host-dies-mid-op race) and wait for each to unregister before
1204 // touching the worktree. The directive reaches a cross-window target via
1205 // its heartbeat reply — the only channel the daemon has to a window it
1206 // can reply to but never call.
1207 let others: Vec<String> = open_windows
1208 .iter()
1209 .map(|(k, _)| k.clone())
1210 .filter(|k| req.requester_key.as_deref() != Some(k))
1211 .collect();
1212 // A self-close is the requester closing a window it owns: it never rides
1213 // the cross-window signal (it acts on our `ok:true` reply instead). Logged
1214 // (#1364) so the execute's routing decision is auditable before the wait,
1215 // even if that wait then hangs or times out.
1216 let self_close = is_self_close(req.requester_key.as_deref(), &open_windows);
1217 log_executing(
1218 &req.path,
1219 req.requester_key.as_deref(),
1220 req.remove,
1221 self_close,
1222 others.len(),
1223 );
1224 for key in &others {
1225 self.registry.mark_close_pending(key);
1226 }
1227 if !others.is_empty() {
1228 if let Err(err) = await_windows_closed(
1229 &self.registry,
1230 &req.path,
1231 req.requester_key.as_deref(),
1232 CLOSE_WAIT_TIMEOUT,
1233 CLOSE_WAIT_POLL,
1234 )
1235 .await
1236 {
1237 log_close_abort(&req.path, &err);
1238 return Err(err);
1239 }
1240 }
1241
1242 if req.remove {
1243 let path = req.path.clone();
1244 // Taken *after* the wait above, so concurrent executes still overlap
1245 // their heartbeat waits (#1359) and only the prune itself serializes.
1246 // Load-bearing placement, not incidental: hoisting this above
1247 // `await_windows_closed` would restack the waits and undo the whole
1248 // point. Pinned by `concurrent_closes_overlap_their_heartbeat_waits`.
1249 let _guard = self.prune_lock.lock().await;
1250 let removed = tokio::task::spawn_blocking(move || remove_worktree(&path))
1251 .await
1252 .map_err(|e| anyhow!("worktree removal task panicked: {e}"))
1253 .map_err(|err| log_close_error(&req.path, "removal task", err))?;
1254 // The audit line + Result→reply mapping lives in a sync helper so the
1255 // destructive outcome is unit-testable off the runtime (#1364).
1256 log_and_map_removal(&req.path, removed)
1257 } else {
1258 // "Close Window" with no owning window is a no-op success; a
1259 // self-close replies and the extension closes its own window.
1260 log_window_closed(&req.path);
1261 Ok(json!({ "closed": true }))
1262 }
1263 }
1264}
1265
1266impl Default for WorktreesService {
1267 fn default() -> Self {
1268 Self::new()
1269 }
1270}
1271
1272#[async_trait]
1273impl DaemonService for WorktreesService {
1274 fn name(&self) -> &'static str {
1275 SERVICE_NAME
1276 }
1277
1278 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
1279 match op {
1280 "register" => {
1281 let req: RegisterRequest =
1282 serde_json::from_value(payload).context("invalid `register` payload")?;
1283 if req.key.trim().is_empty() {
1284 bail!("`register` requires a non-empty `key`");
1285 }
1286 self.registry.register(req);
1287 Ok(json!({ "ok": true }))
1288 }
1289 "heartbeat" => {
1290 let key = require_str(&payload, "key", "heartbeat")?;
1291 let known = self.registry.heartbeat(key);
1292 // A pending close directive (#1277) rides the reply as an
1293 // additive `close` field, taken-and-cleared here so it fires
1294 // exactly once. Omitted when false to keep older windows — which
1295 // read only `known` — byte-identical on the wire.
1296 let mut reply = json!({ "known": known });
1297 if self.registry.take_close_pending(key) {
1298 reply["close"] = Value::Bool(true);
1299 }
1300 Ok(reply)
1301 }
1302 "unregister" => {
1303 let key = require_str(&payload, "key", "unregister")?;
1304 Ok(json!({ "removed": self.registry.unregister(key) }))
1305 }
1306 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
1307 "tree" => {
1308 // The same `{ repos, show_closed }` snapshot the `subscribe`
1309 // stream pushes, so a one-shot `tree` fetch and the live stream
1310 // agree byte-for-byte (the git enumeration runs off-lock on a
1311 // blocking thread inside the helper). Computed fresh here — the
1312 // `tree` op is a rare manual refresh, deliberately bypassing the
1313 // stream's coalescing cache so it never returns a stale view
1314 // (#1303).
1315 Ok(tree_snapshot(&self.registry, self.pr_cache.clone()).await)
1316 }
1317 "ahead-behind" => {
1318 // Lazy per-worktree divergence (#1306). The `tree`/`subscribe`
1319 // snapshot no longer carries ahead/behind — the dominant
1320 // per-worktree cost when computed eagerly every tick — so a client
1321 // (the extension on expand, `worktrees tree`) asks for it here only
1322 // for the worktrees it is about to show. Batched by path, one op per
1323 // repo expand; the git walks run on a blocking thread.
1324 let paths = payload
1325 .get("paths")
1326 .and_then(Value::as_array)
1327 .map(|arr| {
1328 arr.iter()
1329 .filter_map(Value::as_str)
1330 .map(PathBuf::from)
1331 .collect::<Vec<_>>()
1332 })
1333 .unwrap_or_default();
1334 Ok(json!({ "results": ahead_behind_results(paths).await }))
1335 }
1336 "set-show-closed" => {
1337 // The daemon-backed show/hide-closed toggle (#1301). Setting it
1338 // bumps the change-notify, so every subscribed window re-pushes a
1339 // snapshot carrying the new `show_closed` — reliable cross-window
1340 // sync `context.globalState` could not do.
1341 let show_closed = payload
1342 .get("show_closed")
1343 .and_then(Value::as_bool)
1344 .ok_or_else(|| anyhow!("`set-show-closed` requires a boolean `show_closed`"))?;
1345 self.registry.set_show_closed(show_closed);
1346 Ok(json!({ "ok": true }))
1347 }
1348 "set-polling" => {
1349 // Per-repo PR-poll toggle (#1376). Enabling a repo starts the
1350 // poller resolving its badges (default is off — zero `gh`);
1351 // disabling stops it and drops its badges. `set_polling` bumps the
1352 // change-notify on a real change, so every subscribed window
1353 // re-pushes a `tree` snapshot carrying the new per-repo
1354 // `polling_enabled` — the reliable cross-window sync the
1355 // `set-show-closed` precedent relies on. A changed value is
1356 // persisted so it survives a daemon restart.
1357 let owner = require_str(&payload, "owner", "set-polling")?;
1358 let name = require_str(&payload, "name", "set-polling")?;
1359 let enabled = payload
1360 .get("enabled")
1361 .and_then(Value::as_bool)
1362 .ok_or_else(|| anyhow!("`set-polling` requires a boolean `enabled`"))?;
1363 if owner.trim().is_empty() || name.trim().is_empty() {
1364 bail!("`set-polling` requires a non-empty `owner` and `name`");
1365 }
1366 if self.registry.set_polling(owner, name, enabled) {
1367 self.persist_polling_prefs();
1368 }
1369 Ok(json!({ "ok": true }))
1370 }
1371 "open-prs" => {
1372 // Serve "Open Pull Request…" (and the extension's transient badge
1373 // fallback) from the daemon (#1389, fix 7): one shared, TTL-cached,
1374 // #1387-counted `gh pr list` per repo, so N windows dedupe to one
1375 // call instead of each shelling its own (the per-window burn
1376 // #1370/#1389 target). Repo-wide; the client filters by branch for a
1377 // worktree-scoped lookup, and answers a badged branch straight from
1378 // the snapshot (zero `gh`) without ever reaching here.
1379 let owner = require_str(&payload, "owner", "open-prs")?;
1380 let name = require_str(&payload, "name", "open-prs")?;
1381 if owner.trim().is_empty() || name.trim().is_empty() {
1382 bail!("`open-prs` requires a non-empty `owner` and `name`");
1383 }
1384 Ok(json!({ "pull_requests": self.open_prs(owner, name).await? }))
1385 }
1386 "open" => {
1387 // Focus (or open — VS Code reuses an already-open window) an
1388 // arbitrary worktree folder supplied by a socket client, reusing
1389 // the tray's launcher path: `focus_window` resolves the launcher
1390 // (`OMNI_DEV_VSCODE_BIN` → well-known paths → `code`) and applies
1391 // the absolute-existing-directory guard (which also blocks a
1392 // `-`-leading path being parsed by `code` as a flag). This is the
1393 // one op a socket *writer* can use to spawn `code`; see the
1394 // ADR-0040 threat model (#1266).
1395 let path = require_str(&payload, "path", "open")?;
1396 focus_window(Path::new(path))?;
1397 Ok(json!({ "ok": true }))
1398 }
1399 "close" => {
1400 // Close a worktree's window and (for a linked worktree)
1401 // **delete** it. Destructive, so all git logic stays in the
1402 // daemon (git2, never a shell) and the main working tree is
1403 // refused defensively — the UI gating is not the only guard.
1404 // See ADR-0049 and docs/worktrees-service.md.
1405 let req: CloseRequest =
1406 serde_json::from_value(payload).context("invalid `close` payload")?;
1407 self.close(req).await
1408 }
1409 other => bail!("unknown worktrees op: {other}"),
1410 }
1411 }
1412
1413 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
1414 // The single streaming op: a live push of the repo/worktree `tree`
1415 // snapshot. Every other op falls through to the request→reply `handle`.
1416 if op != "subscribe" {
1417 return None;
1418 }
1419 Some(Box::new(WorktreesStream {
1420 // Every stream reads through the one shared cache, so N windows
1421 // sampling the same tick build the tree once, not N times (#1303).
1422 cache: self.tree_cache.clone(),
1423 // Capture the change source *now* — before the server takes its
1424 // initial snapshot — so a change racing that snapshot still wakes us.
1425 changes: self.registry.subscribe_changes(),
1426 }))
1427 }
1428
1429 fn menu(&self) -> MenuSnapshot {
1430 // Serve the snapshot the background task maintains off the main thread;
1431 // fall back to a one-off inline compute only before the first refresh
1432 // lands (or with no runtime — the unit tests). Never blocks on git here
1433 // in the daemon, honouring the trait's "cheap, must not block" contract.
1434 let cached = self
1435 .menu_cache
1436 .lock()
1437 .unwrap_or_else(PoisonError::into_inner)
1438 .clone();
1439 let items = cached.unwrap_or_else(|| {
1440 menu_items_for(&self.registry.list(), self.rate_limit_cache.get().as_ref())
1441 });
1442 MenuSnapshot {
1443 title: SUBMENU_TITLE.to_string(),
1444 items,
1445 }
1446 }
1447
1448 async fn menu_action(&self, action_id: &str) -> Result<()> {
1449 if let Some(key) = action_id.strip_prefix("focus:") {
1450 // The registry resolves the folder under its own lock and clones it
1451 // out, so the mutex is never held across the process launch.
1452 let folder = self
1453 .registry
1454 .first_folder(key)
1455 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
1456 focus_window(&folder)?;
1457 return Ok(());
1458 }
1459 bail!("unknown worktrees menu action: {action_id}")
1460 }
1461
1462 async fn status(&self) -> ServiceStatus {
1463 let entries = self.registry.list();
1464 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
1465 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
1466 let windows = enriched_windows(entries).await;
1467 ServiceStatus {
1468 name: SERVICE_NAME.to_string(),
1469 healthy: true,
1470 summary,
1471 detail: json!({ "windows": windows }),
1472 }
1473 }
1474
1475 async fn shutdown(&self) {
1476 // Stop the background menu-refresh task; the registry itself is in-memory
1477 // with nothing to drain or persist. Take the task out from under the lock
1478 // first so the `std::Mutex` is never held across the `.await`.
1479 let task = self
1480 .refresh
1481 .lock()
1482 .unwrap_or_else(PoisonError::into_inner)
1483 .take();
1484 if let Some(task) = task {
1485 task.token.cancel();
1486 let _ = task.handle.await;
1487 }
1488 // Same discipline for the PR badge poller (#1337): take it out from under
1489 // its lock before awaiting, so no `std::Mutex` is held across the `.await`.
1490 let poller = self
1491 .poller
1492 .lock()
1493 .unwrap_or_else(PoisonError::into_inner)
1494 .take();
1495 if let Some(poller) = poller {
1496 poller.token.cancel();
1497 let _ = poller.handle.await;
1498 }
1499 // And the GitHub rate-limit poller (#1375), same discipline.
1500 let rate_limit_poller = self
1501 .rate_limit_poller
1502 .lock()
1503 .unwrap_or_else(PoisonError::into_inner)
1504 .take();
1505 if let Some(poller) = rate_limit_poller {
1506 poller.token.cancel();
1507 let _ = poller.handle.await;
1508 }
1509 }
1510}
1511
1512/// Extracts a required string `field` from an op payload, erroring with the op
1513/// name when it is absent or not a string. Shared by `heartbeat`/`unregister`
1514/// (`key`) and `open` (`path`).
1515fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
1516 payload
1517 .get(field)
1518 .and_then(Value::as_str)
1519 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
1520}
1521
1522/// The live git state of a worktree folder: the checked-out branch and how far
1523/// it has diverged from its upstream. Computed on read from the on-disk repo
1524/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
1525/// snapshot taken at registration.
1526///
1527/// Every field is optional and degrades independently: a folder that is not a
1528/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
1529/// listed — just without the fields it cannot supply. The `skip_serializing_if`
1530/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
1531/// omitting each absent field on the wire.
1532#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
1533struct GitStatus {
1534 /// The checked-out branch, or `None` when detached or not in a repo.
1535 #[serde(skip_serializing_if = "Option::is_none")]
1536 branch: Option<String>,
1537 /// The commit HEAD points at, or `None` when unborn or not in a repo. Present
1538 /// even on a detached HEAD, which has a commit but no branch. Rides the
1539 /// streamed snapshot so a new commit is a real delta the server's diff cannot
1540 /// drop — without it, a push serialises byte-identically and no client
1541 /// re-renders (#1337).
1542 #[serde(skip_serializing_if = "Option::is_none")]
1543 head_sha: Option<String>,
1544 /// The commit the branch's configured upstream ref points at, or `None`
1545 /// without an upstream (or when detached, unborn, or not in a repo). Rides
1546 /// the streamed snapshot for the same reason as `head_sha`, one ref over: a
1547 /// **push** moves only `refs/remotes/<remote>/<branch>`, leaving every other
1548 /// field — `head_sha` included — byte-identical, so without this the frame
1549 /// serialised the same, the server's diff dropped it, and the lazily-fetched
1550 /// ahead/behind was never re-asked (#1344).
1551 #[serde(skip_serializing_if = "Option::is_none")]
1552 upstream_sha: Option<String>,
1553 /// Commits the branch is ahead of its upstream (`None` without an upstream).
1554 #[serde(skip_serializing_if = "Option::is_none")]
1555 ahead: Option<usize>,
1556 /// Commits the branch is behind its upstream (`None` without an upstream).
1557 #[serde(skip_serializing_if = "Option::is_none")]
1558 behind: Option<usize>,
1559 /// The main repository's directory name — the parent repo for a linked
1560 /// worktree, the checkout's own directory otherwise. Derived from git's
1561 /// common dir so a worktree names the repo it belongs to rather than its
1562 /// worktree-folder basename. `None` when not in a repo.
1563 #[serde(skip_serializing_if = "Option::is_none")]
1564 main_repo: Option<String>,
1565 /// Whether the enriched folder is a **linked** git worktree rather than the
1566 /// repository's main working tree. Omitted (false) for a normal checkout.
1567 #[serde(skip_serializing_if = "is_false")]
1568 is_worktree: bool,
1569}
1570
1571/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
1572/// field is dropped on the wire unless set — keeping older clients byte-identical
1573/// (the protocol's forward-compatibility convention).
1574#[allow(clippy::trivially_copy_pass_by_ref)]
1575fn is_false(b: &bool) -> bool {
1576 !*b
1577}
1578
1579/// One persisted PR-poll lease: the GitHub repo (`"owner/name"`) and when its
1580/// lease expires (#1376). Storing the expiry — not just the repo — is what lets a
1581/// daemon restart within the lease window keep the *remaining* time rather than
1582/// resetting the 15-minute clock; an already-expired entry is dropped on load.
1583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1584struct PollingLease {
1585 repo: String,
1586 expires_at: DateTime<Utc>,
1587}
1588
1589/// The on-disk shape of the per-repo PR-poll prefs (#1376): the live leases whose
1590/// PR badges the daemon polls. Only **enabled** (leased) repos are stored —
1591/// absence means not-polled (the default-off model) — so the file stays small (a
1592/// handful of active repos out of many open). `#[serde(default)]` so an
1593/// empty/older file decodes to "nothing enabled".
1594#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1595struct PollingPrefs {
1596 #[serde(default)]
1597 enabled: Vec<PollingLease>,
1598}
1599
1600/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the
1601/// parent runtime dir (`0700`) if needed — the bridge-token persistence pattern
1602/// (`BridgeService`), reusing the same [`crate::daemon::paths`] helpers.
1603fn write_polling_prefs(path: &Path, prefs: &PollingPrefs) -> Result<()> {
1604 if let Some(parent) = path.parent() {
1605 crate::daemon::paths::ensure_dir_0700(parent)?;
1606 }
1607 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize polling prefs")?;
1608 crate::daemon::paths::write_file_0600(path, &json)
1609}
1610
1611// --- PR-badge cache persistence (#1389, fix 4) -----------------------------
1612
1613/// A persisted badge — the disk twin of [`PrBadge`](crate::pr_status::PrBadge).
1614///
1615/// A distinct DTO rather than reusing `PrBadge`'s derive because the two shapes
1616/// disagree: `PrBadge` renders onto the **tree wire**, where `head_oid` is
1617/// `#[serde(skip)]` (it is a local staleness key, never sent) and `is_draft` is
1618/// `isDraft`. The cache file must round-trip `head_oid` — a restored verdict is
1619/// compared against the worktree's current HEAD via
1620/// [`PrBadge::is_stale_for`](crate::pr_status::PrBadge::is_stale_for), and a lost
1621/// `head_oid` would render every restored badge stale — so it carries the field
1622/// explicitly under a stable snake_case name.
1623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1624struct PersistedBadge {
1625 number: u64,
1626 is_draft: bool,
1627 checks: PrCheckState,
1628 url: String,
1629 head_oid: String,
1630}
1631
1632/// A persisted resolution — the disk twin of
1633/// [`PrResolution`](crate::pr_status::PrResolution). Externally tagged so the
1634/// no-PR negative (#1370) round-trips as a plain `"NoPr"` and a badge as
1635/// `{ "Pr": { … } }`.
1636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1637enum PersistedResolution {
1638 Pr(PersistedBadge),
1639 NoPr,
1640}
1641
1642/// One persisted cache entry: which target, and the verdict last resolved for it.
1643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1644struct PersistedEntry {
1645 target: PrTarget,
1646 resolution: PersistedResolution,
1647}
1648
1649/// A persisted watch — the `(target, upstream_sha)` the poller compared at the
1650/// last fetch. Lets a warm start tell "same set, verdicts still fresh → skip the
1651/// immediate re-poll" from "a target was added or a branch pushed while we were
1652/// down → fetch" (the [`pr_watch_grew`] comparison, restored across a restart).
1653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1654struct PersistedWatch {
1655 target: PrTarget,
1656 #[serde(default, skip_serializing_if = "Option::is_none")]
1657 upstream_sha: Option<String>,
1658}
1659
1660/// The on-disk shape of the resolved PR-badge cache (#1389, fix 4): the badges,
1661/// the watch set they were resolved for, and when. `#[serde(default)]` throughout
1662/// so an empty/older/partial file decodes to "nothing restored" rather than
1663/// failing the load — best-effort, exactly like [`PollingPrefs`].
1664#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1665struct PrCachePrefs {
1666 #[serde(default)]
1667 entries: Vec<PersistedEntry>,
1668 #[serde(default)]
1669 watched: Vec<PersistedWatch>,
1670 #[serde(default, skip_serializing_if = "Option::is_none")]
1671 polled_at: Option<DateTime<Utc>>,
1672}
1673
1674impl PersistedResolution {
1675 /// The disk form of a live resolution.
1676 fn from_resolution(r: &PrResolution) -> Self {
1677 match r {
1678 PrResolution::Pr(b) => Self::Pr(PersistedBadge {
1679 number: b.number,
1680 is_draft: b.is_draft,
1681 checks: b.checks,
1682 url: b.url.clone(),
1683 head_oid: b.head_oid.clone(),
1684 }),
1685 PrResolution::NoPr => Self::NoPr,
1686 }
1687 }
1688
1689 /// The live form of a restored resolution.
1690 fn into_resolution(self) -> PrResolution {
1691 match self {
1692 Self::Pr(b) => PrResolution::Pr(PrBadge {
1693 number: b.number,
1694 is_draft: b.is_draft,
1695 checks: b.checks,
1696 url: b.url,
1697 head_oid: b.head_oid,
1698 }),
1699 Self::NoPr => PrResolution::NoPr,
1700 }
1701 }
1702}
1703
1704/// Assembles the on-disk cache from the live cache entries, the watch they were
1705/// resolved for, and the poll time.
1706fn pr_cache_prefs_from(
1707 entries: Vec<(PrTarget, PrResolution)>,
1708 watched: &[PrWatch],
1709 polled_at: DateTime<Utc>,
1710) -> PrCachePrefs {
1711 let mut entries: Vec<PersistedEntry> = entries
1712 .into_iter()
1713 .map(|(target, resolution)| PersistedEntry {
1714 target,
1715 resolution: PersistedResolution::from_resolution(&resolution),
1716 })
1717 .collect();
1718 // Stable on-disk order so the file does not churn on rewrite from `HashMap`
1719 // iteration order alone (the same reason `PollingPrefs` sorts).
1720 entries.sort_by(|a, b| a.target.cmp(&b.target));
1721 let mut watched: Vec<PersistedWatch> = watched
1722 .iter()
1723 .map(|w| PersistedWatch {
1724 target: w.target.clone(),
1725 upstream_sha: w.upstream_sha.clone(),
1726 })
1727 .collect();
1728 watched.sort_by(|a, b| a.target.cmp(&b.target));
1729 PrCachePrefs {
1730 entries,
1731 watched,
1732 polled_at: Some(polled_at),
1733 }
1734}
1735
1736/// Writes `prefs` to `path` as pretty JSON with `0600` perms, creating the parent
1737/// runtime dir (`0700`) if needed — the [`write_polling_prefs`] pattern.
1738fn write_pr_cache(path: &Path, prefs: &PrCachePrefs) -> Result<()> {
1739 if let Some(parent) = path.parent() {
1740 crate::daemon::paths::ensure_dir_0700(parent)?;
1741 }
1742 let json = serde_json::to_vec_pretty(prefs).context("failed to serialize PR cache")?;
1743 crate::daemon::paths::write_file_0600(path, &json)
1744}
1745
1746/// Persists the current PR-badge cache to `path` (best-effort). Reads the live
1747/// entries off `pr_cache`, pairs them with the `watched` set and `polled_at`, and
1748/// writes the `0600` file; a failure is logged at WARN and swallowed, since the
1749/// in-memory cache is authoritative for the running daemon and losing the warm
1750/// start only costs one extra poll after the next restart.
1751fn persist_pr_cache(
1752 path: &Path,
1753 pr_cache: &PrStatusCache,
1754 watched: &[PrWatch],
1755 polled_at: DateTime<Utc>,
1756) {
1757 let prefs = pr_cache_prefs_from(pr_cache.entries(), watched, polled_at);
1758 if let Err(err) = write_pr_cache(path, &prefs) {
1759 let at = path.display();
1760 tracing::warn!("could not persist worktrees PR cache to {at}: {err:#}");
1761 }
1762}
1763
1764/// Warm-start state restored from the persisted PR-badge cache (#1389, fix 4):
1765/// the watch set the previous daemon last resolved and when it last polled. The
1766/// poller seeds its loop from this so a restart within the backoff window skips
1767/// the immediate re-poll for verdicts it already holds, instead of spending a `gh`
1768/// call to re-derive what the `0600` file already carries.
1769#[derive(Debug, Clone)]
1770struct PrWarmStart {
1771 /// The `(target, upstream_sha)` set the persisted verdicts describe.
1772 watched: Vec<PrWatch>,
1773 /// When those verdicts were resolved, used to age the warm start against the
1774 /// backoff (a stale-enough file just re-polls).
1775 polled_at: DateTime<Utc>,
1776}
1777
1778// --- Shared open-PR cache for the daemon-served "Open PR" op (#1389, fix 7) -----
1779
1780/// One cached `gh pr list` result: the forwarded JSON PR array and when it was
1781/// fetched, for TTL expiry.
1782#[derive(Debug, Clone)]
1783struct OpenPrEntry {
1784 at: Instant,
1785 prs: Vec<Value>,
1786}
1787
1788/// A shared, TTL'd cache of `gh pr list` results per repo (#1389, fix 7).
1789///
1790/// Serving "Open Pull Request…" — and the extension's transient badge fallback —
1791/// from the daemon means N windows asking about one repo dedupe to a single counted
1792/// `gh pr list` within the TTL, instead of each window shelling its own (the
1793/// per-window burn #1370/#1389 target). A plain temporal cache, **no single-flight**:
1794/// the access pattern is a manual action (or a brief post-enable transient), so two
1795/// exactly-concurrent misses for the same repo — costing one extra `gh` — are rare
1796/// and harmless, while the common repeat-within-TTL is served for free. The lock is
1797/// never held across an `.await`.
1798#[derive(Debug)]
1799struct OpenPrCache {
1800 entries: Mutex<HashMap<String, OpenPrEntry>>,
1801 ttl: Duration,
1802}
1803
1804impl OpenPrCache {
1805 fn new(ttl: Duration) -> Self {
1806 Self {
1807 entries: Mutex::new(HashMap::new()),
1808 ttl,
1809 }
1810 }
1811
1812 /// The cached PRs for `key` (`owner/name`) while still within the TTL, else
1813 /// `None` (a miss that the caller resolves with a fresh `gh`).
1814 fn fresh(&self, key: &str) -> Option<Vec<Value>> {
1815 self.entries
1816 .lock()
1817 .unwrap_or_else(PoisonError::into_inner)
1818 .get(key)
1819 .filter(|e| e.at.elapsed() < self.ttl)
1820 .map(|e| e.prs.clone())
1821 }
1822
1823 /// Records a freshly-fetched PR list for `key`.
1824 fn store(&self, key: String, prs: Vec<Value>) {
1825 self.entries
1826 .lock()
1827 .unwrap_or_else(PoisonError::into_inner)
1828 .insert(
1829 key,
1830 OpenPrEntry {
1831 at: Instant::now(),
1832 prs,
1833 },
1834 );
1835 }
1836}
1837
1838/// Runs `gh pr list` for `slug` (`owner/name`) through the #1387-counted `run_gh`
1839/// choke point and parses the JSON array of open PRs. **Blocking** (a subprocess) —
1840/// call on a blocking thread, never an async worker. The array is forwarded to the
1841/// extension verbatim, which parses it into its `PullRequest` shape.
1842fn open_pr_list(bin: &Path, slug: &str) -> Result<Vec<Value>> {
1843 let output = crate::github_metrics::run_gh(
1844 bin,
1845 [
1846 "pr",
1847 "list",
1848 "--repo",
1849 slug,
1850 "--state",
1851 "open",
1852 "--json",
1853 OPEN_PR_JSON_FIELDS,
1854 "--limit",
1855 OPEN_PR_LIST_LIMIT,
1856 ],
1857 "pr list",
1858 None,
1859 )
1860 .with_context(|| {
1861 format!(
1862 "failed to run {} (is the GitHub CLI installed?)",
1863 bin.display()
1864 )
1865 })?;
1866 if !output.status.success() {
1867 let stderr = String::from_utf8_lossy(&output.stderr);
1868 bail!("gh pr list failed: {}", stderr.trim());
1869 }
1870 match serde_json::from_slice(&output.stdout).context("gh pr list returned invalid JSON")? {
1871 Value::Array(arr) => Ok(arr),
1872 _ => bail!("gh pr list did not return a JSON array"),
1873 }
1874}
1875
1876/// Computes the **full** [`GitStatus`] of `folder` — branch, repo identity, and
1877/// the ahead/behind divergence from upstream. Used by the one-shot `list`/`status`
1878/// op and the tray menu, both bounded to the (few) open windows, where the extra
1879/// `graph_ahead_behind` walk is negligible. The streamed `tree` snapshot uses the
1880/// cheaper [`git_status_cheap`] instead and fetches divergence on demand (#1306).
1881fn git_status(folder: &Path) -> GitStatus {
1882 git_status_impl(folder, true)
1883}
1884
1885/// Computes the **cheap** [`GitStatus`] of `folder` — branch and repo identity
1886/// only, skipping the (expensive) `graph_ahead_behind` upstream revwalk. Used by
1887/// the `tree`/`subscribe` snapshot, which is rebuilt for **every** worktree on
1888/// **every** tick: divergence there is computed lazily via the `ahead-behind` op
1889/// only for the worktrees a client actually looks at (#1306). The `ahead`/`behind`
1890/// fields stay `None`, so they are omitted on the wire exactly as for a branch
1891/// with no upstream.
1892fn git_status_cheap(folder: &Path) -> GitStatus {
1893 git_status_impl(folder, false)
1894}
1895
1896/// The shared body of [`git_status`] / [`git_status_cheap`]: discovers the
1897/// repository that contains `folder` — so a subdirectory or a linked worktree both
1898/// resolve — reads HEAD, and (only when `with_ahead_behind`) walks the upstream
1899/// divergence. Every failure mode degrades to an empty status rather than
1900/// erroring: the enrichment is best-effort and must never sink a `list` or a tree.
1901fn git_status_impl(folder: &Path, with_ahead_behind: bool) -> GitStatus {
1902 let Ok(repo) = Repository::discover(folder) else {
1903 return GitStatus::default();
1904 };
1905 // Repo identity applies even when HEAD is unborn or detached, so a worktree
1906 // still names its parent repo (and is flagged as a worktree) in those states.
1907 let base = GitStatus {
1908 main_repo: main_repo_name(repo.commondir()),
1909 is_worktree: repo.is_worktree(),
1910 ..GitStatus::default()
1911 };
1912 let Ok(head) = repo.head() else {
1913 // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
1914 return base;
1915 };
1916 // Resolved here — before the branch filter below, so a detached HEAD still
1917 // reports its commit, and before `Branch::wrap` consumes `head`. `target()` is
1918 // a refs read: no revwalk and no object lookup, so unlike the divergence walk
1919 // it is cheap enough for the streamed snapshot's every-worktree-every-tick
1920 // rebuild (#1306's bar).
1921 let base = GitStatus {
1922 head_sha: head.target().map(|oid| oid.to_string()),
1923 ..base
1924 };
1925 // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
1926 // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
1927 // name — degrades to no branch through this one path.
1928 let Some(name) = head
1929 .shorthand()
1930 .ok()
1931 .filter(|_| head.is_branch())
1932 .map(str::to_string)
1933 else {
1934 return base;
1935 };
1936 // Consumes `head`, so it has to follow the `shorthand()` read above. A pure
1937 // type wrapper — no I/O — so hoisting it out of the `with_ahead_behind` arm
1938 // below costs the cheap path nothing, and is what gives it a handle to
1939 // resolve the upstream from.
1940 let branch = git2::Branch::wrap(head);
1941 // Unlike the divergence walk, this rides both paths: it is what makes a push
1942 // a visible delta (#1344).
1943 let upstream_sha = upstream_target(&branch);
1944 // The divergence walk is the dominant per-worktree cost, so the cheap path
1945 // skips it and leaves ahead/behind absent.
1946 let (ahead, behind) = if with_ahead_behind {
1947 match upstream_ahead_behind(&repo, &branch) {
1948 Some((ahead, behind)) => (Some(ahead), Some(behind)),
1949 None => (None, None),
1950 }
1951 } else {
1952 (None, None)
1953 };
1954 GitStatus {
1955 branch: Some(name),
1956 upstream_sha,
1957 ahead,
1958 behind,
1959 ..base
1960 }
1961}
1962
1963/// The commit `branch`'s configured upstream ref points at, or `None` when it
1964/// tracks no upstream (or the ref is unresolvable).
1965///
1966/// Costs a config lookup (`branch.<name>.remote` + `.merge`) and a
1967/// remote-tracking refs read — more than [`git_status_impl`]'s single `head`
1968/// refs read, but still **no revwalk and no object lookup**, which is the bar
1969/// #1306 set for the snapshot's every-worktree-every-tick rebuild and the one
1970/// `graph_ahead_behind` fails. [`upstream_ahead_behind`] already resolves the
1971/// same OID, so it is proven reachable.
1972fn upstream_target(branch: &git2::Branch<'_>) -> Option<String> {
1973 Some(branch.upstream().ok()?.get().target()?.to_string())
1974}
1975
1976/// The ahead/behind divergence of `folder`'s checked-out branch versus its
1977/// upstream, computed on demand for the lazy `ahead-behind` op (#1306). Mirrors the
1978/// branch resolution in [`git_status_impl`] but does **only** the upstream walk
1979/// [`git_status_cheap`] omits. `None` when `folder` is not a repo, is on a detached
1980/// or unborn HEAD, or tracks no upstream — every case the tree renders without a
1981/// sync indicator.
1982fn folder_ahead_behind(folder: &Path) -> Option<(usize, usize)> {
1983 let repo = Repository::discover(folder).ok()?;
1984 let head = repo.head().ok()?;
1985 if !head.is_branch() {
1986 return None;
1987 }
1988 let branch = git2::Branch::wrap(head);
1989 upstream_ahead_behind(&repo, &branch)
1990}
1991
1992/// The main repository's directory name from git's common dir. For the usual
1993/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
1994/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
1995/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
1996/// no name can be derived.
1997fn main_repo_name(commondir: &Path) -> Option<String> {
1998 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
1999 if file_name == ".git" {
2000 // Normal layout: the repo is the directory that contains `.git`.
2001 commondir
2002 .parent()
2003 .and_then(Path::file_name)
2004 .map(|n| n.to_string_lossy().into_owned())
2005 } else {
2006 // A bare repo: use its own directory name, without any `.git` suffix.
2007 Some(
2008 file_name
2009 .strip_suffix(".git")
2010 .unwrap_or(&file_name)
2011 .to_string(),
2012 )
2013 }
2014}
2015
2016/// Ahead/behind commit counts of `branch` versus its configured upstream, or
2017/// `None` when the branch tracks no upstream (or either tip is unresolvable).
2018fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
2019 let upstream = branch.upstream().ok()?;
2020 let local_oid = branch.get().target()?;
2021 let upstream_oid = upstream.get().target()?;
2022 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
2023}
2024
2025/// The wire shape of an enriched window: the stored entry fields plus the
2026/// daemon-computed git state, flattened into one JSON object. Serializing
2027/// through a single struct (rather than mutating a `Value`) keeps every present
2028/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
2029/// the absent git fields — no manual per-field insertion.
2030#[derive(Serialize)]
2031struct EnrichedEntry<'a> {
2032 #[serde(flatten)]
2033 entry: &'a WindowEntry,
2034 #[serde(flatten)]
2035 git: GitStatus,
2036}
2037
2038/// Serializes a registry entry and folds in the live [`git_status`] of its
2039/// primary (first) folder, producing the JSON object served on the wire
2040/// (`list`/`status`) and read by the extension UI. Only the primary folder is
2041/// enriched — it is the one the table shows and the "focus" action opens.
2042fn enriched_entry(entry: &WindowEntry) -> Value {
2043 let git = entry
2044 .folders
2045 .first()
2046 .map(|folder| git_status(folder))
2047 .unwrap_or_default();
2048 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
2049}
2050
2051/// Enriches a batch of entries with their git state on a blocking thread, since
2052/// `git2` does synchronous disk I/O and this runs inside the async control-socket
2053/// handler. A join failure degrades to an empty list rather than erroring.
2054async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
2055 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
2056 .await
2057 .unwrap_or_default()
2058}
2059
2060// --- Repo/worktree tree (#1265) ----------------------------------------------
2061
2062/// A GitHub `owner/name` identity parsed from a repository's `origin` remote.
2063/// Present on a repo in the `tree` payload only for `github.com` remotes; a
2064/// non-GitHub (or remote-less) repo omits it.
2065#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2066struct GithubIdentity {
2067 /// The repository owner (user or org) — the first path segment.
2068 owner: String,
2069 /// The repository name, with any `.git` suffix stripped.
2070 name: String,
2071}
2072
2073/// One worktree of a repository in the `tree` payload: its path, live git state,
2074/// whether it is the main working tree, and whether a VS Code window currently
2075/// has it open (with that window's key, for the focus action). Optional git
2076/// fields degrade independently, exactly like [`GitStatus`].
2077///
2078/// Ahead/behind **divergence** is deliberately absent from this snapshot: it was
2079/// the dominant per-worktree cost when computed eagerly for every worktree on
2080/// every tick, so it is now fetched lazily via the `ahead-behind` op only for the
2081/// worktrees a client actually shows (#1306).
2082///
2083/// The two **OIDs** the divergence is computed from — `head_sha` and
2084/// `upstream_sha` — do ride the snapshot, which is not a contradiction: each is a
2085/// refs read rather than a commit-graph walk, and between them they are what makes
2086/// a commit (#1337) or a push (#1344) a *visible delta*, so a client knows to
2087/// re-ask for the counts it left behind.
2088#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2089struct TreeWorktree {
2090 /// Absolute path to the worktree's working directory.
2091 path: String,
2092 /// The checked-out branch, or `None` when detached or unborn.
2093 #[serde(skip_serializing_if = "Option::is_none")]
2094 branch: Option<String>,
2095 /// The commit HEAD points at, or `None` when unborn. Unlike ahead/behind this
2096 /// **does** ride the snapshot: it costs a refs read, and it is what makes a new
2097 /// commit a visible delta, so a push re-renders instead of being dropped by the
2098 /// server's snapshot diff (#1337).
2099 #[serde(skip_serializing_if = "Option::is_none")]
2100 head_sha: Option<String>,
2101 /// The commit the branch's upstream ref points at, or `None` without an
2102 /// upstream. The push counterpart of `head_sha`: a push moves only
2103 /// `refs/remotes/<remote>/<branch>`, so this is the *one* field that moves —
2104 /// making the snapshot a real delta the server's diff cannot drop, which is
2105 /// what re-fetches the lazy ahead/behind (#1344).
2106 #[serde(skip_serializing_if = "Option::is_none")]
2107 upstream_sha: Option<String>,
2108 /// Whether this is the repository's main working tree (vs a linked worktree).
2109 is_main: bool,
2110 /// Whether a live VS Code window currently has this worktree open.
2111 open: bool,
2112 /// The open window's registry key, when `open` — the handle a focus action
2113 /// resolves. Absent for a worktree with no open window.
2114 #[serde(skip_serializing_if = "Option::is_none")]
2115 window_key: Option<String>,
2116 /// The open PR whose head is this worktree's branch, with its CI verdict
2117 /// (#1337). Resolved by the daemon's background poller and folded on as the
2118 /// snapshot is built, so every open window sees the same live state without
2119 /// each running its own `gh`. Absent for a detached/non-GitHub worktree, one
2120 /// with no open PR (see `pr_none`), or until the first poll lands.
2121 #[serde(skip_serializing_if = "Option::is_none")]
2122 pr: Option<PrBadge>,
2123 /// Set when the daemon **checked GitHub and found no open PR** for this
2124 /// worktree's branch — the explicit negative (#1370), mutually exclusive
2125 /// with `pr`. Omitted (false) whenever `pr` is present, for a branchless or
2126 /// non-GitHub worktree, and — crucially — while the branch is simply **not
2127 /// yet resolved** (before the first poll lands, or ever, on a failed one):
2128 /// `pr` absent *and* `pr_none` absent still means "not resolved", so an
2129 /// older client stays byte-identical (ADR-0053). Clients use it to keep
2130 /// their degraded per-window `gh pr list` fallback quiet for branches the
2131 /// daemon has already answered for.
2132 #[serde(skip_serializing_if = "is_false")]
2133 pr_none: bool,
2134}
2135
2136/// One repository (with **all** its worktrees) in the `tree` payload. Repos are
2137/// derived from the distinct open windows; a repo leaves the tree when its last
2138/// window closes (the open-window-derived model, ADR-0040 / #1264).
2139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2140struct TreeRepo {
2141 /// The main repository's directory name (see [`main_repo_name`]).
2142 main_repo: String,
2143 /// The GitHub identity of `origin`, when it is a `github.com` remote.
2144 #[serde(skip_serializing_if = "Option::is_none")]
2145 github: Option<GithubIdentity>,
2146 /// Absolute path to the main working tree — the repo's root.
2147 root: String,
2148 /// Whether the daemon polls this repo's PR badges (#1376). Stamped from the
2149 /// registry's per-repo enable set, which defaults **off**, so it is omitted
2150 /// (false) for the common not-polled repo — keeping older clients
2151 /// byte-identical — and present (`true`) only for a repo the user has
2152 /// explicitly enabled. The extension colours the repo icon green when set and
2153 /// gates the "Disable PR Polling" menu on it; the daemon's own poller filters
2154 /// on it so a not-polled repo issues zero `gh`.
2155 #[serde(skip_serializing_if = "is_false")]
2156 polling_enabled: bool,
2157 /// Every worktree of the repo: the main working tree first, then linked
2158 /// worktrees sorted by path.
2159 worktrees: Vec<TreeWorktree>,
2160}
2161
2162/// Parses a git remote URL into its GitHub `owner/name`, or `None` for any
2163/// non-GitHub host. Handles the common forms: `https://github.com/o/r(.git)`,
2164/// `http://…`, `ssh://git@github.com/o/r(.git)`, `git://github.com/o/r(.git)`,
2165/// and the SCP-like `git@github.com:o/r(.git)`. A trailing `.git` and trailing
2166/// slashes are stripped; anything with an empty or extra path segment is
2167/// rejected (best-effort, never panics).
2168fn github_identity(url: &str) -> Option<GithubIdentity> {
2169 let url = url.trim();
2170 // Reduce every supported form to the `owner/name…` tail after the host.
2171 let rest = [
2172 "https://github.com/",
2173 "http://github.com/",
2174 "ssh://git@github.com/",
2175 "git://github.com/",
2176 "git@github.com:",
2177 ]
2178 .iter()
2179 .find_map(|prefix| url.strip_prefix(prefix))?;
2180 let rest = rest.strip_suffix(".git").unwrap_or(rest);
2181 let rest = rest.trim_end_matches('/');
2182 let mut parts = rest.splitn(2, '/');
2183 let owner = parts.next()?.trim();
2184 let name = parts.next()?.trim();
2185 // A well-formed identity has exactly two non-empty segments.
2186 if owner.is_empty() || name.is_empty() || name.contains('/') {
2187 return None;
2188 }
2189 Some(GithubIdentity {
2190 owner: owner.to_string(),
2191 name: name.to_string(),
2192 })
2193}
2194
2195/// The GitHub identity of `repo`: `origin`'s URL first, else the first
2196/// `github.com` remote found. `None` when no remote is a GitHub remote.
2197fn remote_github_identity(repo: &Repository) -> Option<GithubIdentity> {
2198 if let Ok(origin) = repo.find_remote("origin") {
2199 if let Some(id) = origin.url().ok().and_then(github_identity) {
2200 return Some(id);
2201 }
2202 }
2203 // `remotes()` yields `Result<Option<&str>, _>` per name; the first flatten
2204 // drops the (per-name) errors, the second the non-UTF-8 `None`s. `names` is
2205 // bound so `iter()` can borrow it (only `&StringArray` is `IntoIterator`).
2206 let names = repo.remotes().ok();
2207 names
2208 .iter()
2209 .flat_map(|arr| arr.iter())
2210 .flatten()
2211 .flatten()
2212 .filter_map(|name| repo.find_remote(name).ok())
2213 .find_map(|remote| remote.url().ok().and_then(github_identity))
2214}
2215
2216/// Canonicalizes a path for stable comparison (resolving symlinks and `..`),
2217/// falling back to the path as-given when it cannot be canonicalized (e.g. it
2218/// no longer exists) so the join still degrades gracefully.
2219fn canonical(path: &Path) -> PathBuf {
2220 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2221}
2222
2223/// Indexes the open windows by canonicalized workspace-folder path → window key,
2224/// so a worktree path can be joined back to the window (if any) that has it open.
2225/// The first window wins a shared folder; `entries` arrive in a deterministic
2226/// (repo, key) order, so the choice is stable.
2227fn open_window_index(entries: &[WindowEntry]) -> HashMap<PathBuf, String> {
2228 let mut index = HashMap::new();
2229 for entry in entries {
2230 for folder in &entry.folders {
2231 index
2232 .entry(canonical(folder))
2233 .or_insert_with(|| entry.key.clone());
2234 }
2235 }
2236 index
2237}
2238
2239/// Builds a [`TreeWorktree`] for `path`: reuses [`git_status_cheap`] for the live
2240/// git state (branch + repo identity, **no** ahead/behind walk — that is lazy per
2241/// #1306) and joins the open-window index for `open`/`window_key`. `is_main` is set
2242/// by the caller from the enumeration (main working tree vs linked).
2243fn worktree_entry(
2244 path: &Path,
2245 is_main: bool,
2246 open_index: &HashMap<PathBuf, String>,
2247) -> TreeWorktree {
2248 let status = git_status_cheap(path);
2249 let window_key = open_index.get(&canonical(path)).cloned();
2250 TreeWorktree {
2251 path: path.display().to_string(),
2252 branch: status.branch,
2253 head_sha: status.head_sha,
2254 upstream_sha: status.upstream_sha,
2255 is_main,
2256 open: window_key.is_some(),
2257 window_key,
2258 // Folded on afterwards by `fold_pr_badges`, which needs the repo's GitHub
2259 // identity — known one level up, in `repo_tree`.
2260 pr: None,
2261 pr_none: false,
2262 }
2263}
2264
2265/// Folds the poller's cached PR resolutions onto each worktree of each repo
2266/// (#1337).
2267///
2268/// Runs after [`build_tree`] because a resolution is keyed by (repo GitHub
2269/// identity, branch) and the identity is only known once the repo is assembled.
2270/// Purely a cache read — no I/O, no network — so it is safe on the snapshot's hot
2271/// path. A non-GitHub repo, a branchless worktree, or an unresolved branch simply
2272/// keeps `pr: None`/`pr_none: false` and renders nothing.
2273///
2274/// A verdict computed for a **different commit** than the worktree has checked out
2275/// is downgraded to pending here rather than shown as-is. That is what makes a push
2276/// invalidate the badge the moment it happens: the cache still holds the previous
2277/// commit's verdict, and this fold — which runs on every snapshot — notices without
2278/// waiting for a poll. Without it the previous head's `✓` stands until the poller
2279/// next runs, which is up to the full backoff.
2280///
2281/// A **negative** ([`PrResolution::NoPr`], #1370) is deliberately *not* dropped
2282/// when `head_sha` moves: it has no commit to be stale against, and dropping it
2283/// would re-arm every client's `gh` fallback on every local commit. The poller's
2284/// `moved` trigger re-checks the branch within one fast poll anyway.
2285/// Stamps each repo's `polling_enabled` flag from the registry's per-repo PR-poll
2286/// enable set (#1376).
2287///
2288/// Runs after [`build_tree`] (which knows the GitHub identity) and **before**
2289/// [`fold_pr_badges`] (which skips a not-polled repo), so a repo the user has not
2290/// enabled carries neither the flag nor any badge. Purely a set membership check —
2291/// no I/O — so it is safe on the snapshot's hot path. A non-GitHub repo has no key
2292/// and stays `false`; it never polls anyway.
2293fn stamp_polling(repos: &mut [TreeRepo], enabled: &HashSet<String>) {
2294 for repo in repos {
2295 if let Some(github) = &repo.github {
2296 repo.polling_enabled = enabled.contains(&format!("{}/{}", github.owner, github.name));
2297 }
2298 }
2299}
2300
2301fn fold_pr_badges(repos: &mut [TreeRepo], pr_cache: &PrStatusCache) {
2302 for repo in repos {
2303 // A not-polled repo (#1376) never carries a badge: skip it so a repo the
2304 // user disabled drops its `pr`/`pr_none` the moment `stamp_polling` clears
2305 // the flag, and so the icon greys cross-window on the next pushed snapshot.
2306 if !repo.polling_enabled {
2307 continue;
2308 }
2309 let Some(github) = repo.github.clone() else {
2310 continue;
2311 };
2312 for worktree in &mut repo.worktrees {
2313 let Some(branch) = &worktree.branch else {
2314 continue;
2315 };
2316 match pr_cache.get(&github.owner, &github.name, branch) {
2317 Some(PrResolution::Pr(mut badge)) => {
2318 if badge.is_stale_for(worktree.head_sha.as_deref()) {
2319 badge.checks = PrCheckState::Pending;
2320 }
2321 worktree.pr = Some(badge);
2322 }
2323 Some(PrResolution::NoPr) => worktree.pr_none = true,
2324 None => {}
2325 }
2326 }
2327 }
2328}
2329
2330/// Enumerates a repository and all its worktrees into a [`TreeRepo`], given a
2331/// handle discovered from one of its folders. Opens the **main** repo from the
2332/// shared common dir's parent so the main working tree and every linked worktree
2333/// are enumerated regardless of which one seeded the discovery. `None` for a
2334/// bare or otherwise root-less repo (no working tree to show).
2335fn repo_tree(discovered: &Repository, open_index: &HashMap<PathBuf, String>) -> Option<TreeRepo> {
2336 // The common dir (`…/<root>/.git`) is shared by the main checkout and all
2337 // linked worktrees; its parent is the main working tree.
2338 let commondir = canonical(discovered.commondir());
2339 let main_root = commondir.parent()?.to_path_buf();
2340 let main_repo = Repository::open(&main_root).ok()?;
2341
2342 // Main working tree first.
2343 let mut worktrees = vec![worktree_entry(&main_root, true, open_index)];
2344 // Then every linked worktree, sorted by path for deterministic output. The
2345 // `StringArray` of names is bound so `iter()` can borrow it (only
2346 // `&StringArray` is `IntoIterator`); a name that no longer resolves to a
2347 // worktree is skipped.
2348 let names = main_repo.worktrees().ok();
2349 let mut linked: Vec<PathBuf> = names
2350 .iter()
2351 .flat_map(|arr| arr.iter())
2352 .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
2353 .flatten() // Option<&str> → &str (drop non-UTF-8 names)
2354 .filter_map(|name| main_repo.find_worktree(name).ok())
2355 .map(|wt| wt.path().to_path_buf())
2356 .collect();
2357 linked.sort();
2358 worktrees.extend(
2359 linked
2360 .iter()
2361 .map(|path| worktree_entry(path, false, open_index)),
2362 );
2363
2364 Some(TreeRepo {
2365 main_repo: main_repo_name(&commondir)?,
2366 github: remote_github_identity(&main_repo),
2367 root: main_root.display().to_string(),
2368 // Defaults off; `stamp_polling` sets it from the registry's enable set
2369 // once the repo (and thus its GitHub identity) is assembled.
2370 polling_enabled: false,
2371 worktrees,
2372 })
2373}
2374
2375/// Resolves the seed `folders` to their distinct repositories and enumerates
2376/// each repo's worktrees. Dedupes repos by their common dir (shared across a
2377/// repo's worktrees) via a `BTreeMap` for deterministic ordering; a folder that
2378/// is not in a git repo is skipped. Pure blocking git I/O — call it via
2379/// [`tree_repos`], never under the registry lock.
2380fn build_tree(folders: Vec<PathBuf>, windows: Vec<WindowEntry>) -> Vec<TreeRepo> {
2381 let open_index = open_window_index(&windows);
2382 let mut repos: BTreeMap<PathBuf, TreeRepo> = BTreeMap::new();
2383 for folder in &folders {
2384 let Ok(repo) = Repository::discover(folder) else {
2385 continue;
2386 };
2387 let key = canonical(repo.commondir());
2388 if repos.contains_key(&key) {
2389 continue;
2390 }
2391 if let Some(tree) = repo_tree(&repo, &open_index) {
2392 repos.insert(key, tree);
2393 }
2394 }
2395 repos.into_values().collect()
2396}
2397
2398/// Enumerates and enriches the repo/worktree tree on a blocking thread (`git2`
2399/// does synchronous disk I/O and this runs inside the async control-socket
2400/// handler), returning the serialized `repos` array. A join failure degrades to
2401/// an empty list rather than erroring, matching [`enriched_windows`].
2402async fn tree_repos(
2403 folders: Vec<PathBuf>,
2404 windows: Vec<WindowEntry>,
2405 pr_cache: Arc<PrStatusCache>,
2406 enabled_polling: HashSet<String>,
2407) -> Vec<Value> {
2408 tokio::task::spawn_blocking(move || {
2409 let mut repos = build_tree(folders, windows);
2410 // Stamp per-repo poll state first so `fold_pr_badges` can skip a
2411 // not-polled repo — a disabled repo carries neither the flag nor a badge.
2412 stamp_polling(&mut repos, &enabled_polling);
2413 fold_pr_badges(&mut repos, &pr_cache);
2414 repos
2415 .iter()
2416 .map(|repo| serde_json::to_value(repo).unwrap_or_else(|_| json!({})))
2417 .collect()
2418 })
2419 .await
2420 .unwrap_or_default()
2421}
2422
2423// --- Lazy ahead/behind (#1306) -----------------------------------------------
2424
2425/// Computes the ahead/behind divergence for a batch of worktree `paths` on demand,
2426/// returning a JSON object keyed by the **requested** path string:
2427/// `{ "<path>": { "ahead": n, "behind": m }, … }`. A path with no upstream (or that
2428/// is not a repo / is detached) is **omitted** — the client renders it without a
2429/// sync indicator, exactly as the tree does for an absent `ahead`/`behind`.
2430///
2431/// Backs the `ahead-behind` op, which exists precisely so the streamed `tree`
2432/// snapshot can stay cheap: a client fetches divergence only for the worktrees it
2433/// shows (the extension on expand), not for every worktree on every tick. The git
2434/// walks are blocking disk I/O, so they run on a blocking thread; a join failure
2435/// degrades to an empty object rather than erroring.
2436async fn ahead_behind_results(paths: Vec<PathBuf>) -> Value {
2437 tokio::task::spawn_blocking(move || {
2438 let mut results = serde_json::Map::new();
2439 for path in paths {
2440 if let Some((ahead, behind)) = folder_ahead_behind(&path) {
2441 results.insert(
2442 path.display().to_string(),
2443 json!({ "ahead": ahead, "behind": behind }),
2444 );
2445 }
2446 }
2447 Value::Object(results)
2448 })
2449 .await
2450 .unwrap_or_else(|_| json!({}))
2451}
2452
2453// --- Push subscription (#1267) -----------------------------------------------
2454
2455/// The [`ServiceStream`] backing the worktrees `subscribe` op: a live push of
2456/// the same `{ repos: [...] }` snapshot the `tree` op returns (#1265). The
2457/// server drives it — awaiting [`changed`](ServiceStream::changed) plus its own
2458/// periodic tick, then diffing [`snapshot`](ServiceStream::snapshot) — so this
2459/// type only has to (a) relay the registry's change-notify and (b) read the
2460/// tree snapshot on demand.
2461///
2462/// Every window's stream shares one [`TreeSnapshotCache`] (#1303): the snapshot
2463/// is built at most once per tick and fanned out, rather than each stream
2464/// rebuilding the identical tree. This type holds only cheap handles — a clone
2465/// of the shared cache and its own change-notify receiver.
2466struct WorktreesStream {
2467 /// The shared coalescing cache the snapshot is read through, so every
2468 /// stream's tick/change re-sample hits one shared `build_tree` (#1303).
2469 cache: Arc<TreeSnapshotCache>,
2470 /// Wakes on each visible-set change (a `register`, a removing `unregister`,
2471 /// or a mutation-driven reap). A burst coalesces into one wakeup; the
2472 /// server's diff drops any snapshot that ends up identical.
2473 changes: watch::Receiver<u64>,
2474}
2475
2476#[async_trait]
2477impl ServiceStream for WorktreesStream {
2478 async fn changed(&mut self) {
2479 // `watch::Receiver::changed` marks the newest version seen, so a burst of
2480 // bumps collapses into a single wakeup. If every sender is gone (the
2481 // registry — and thus the daemon — is tearing down) it returns `Err`;
2482 // park instead of returning, so this arm can never spin the server's
2483 // `select!` (the tick and shutdown arms still drive teardown).
2484 if self.changes.changed().await.is_err() {
2485 std::future::pending::<()>().await;
2486 }
2487 }
2488
2489 async fn snapshot(&self) -> Value {
2490 // Read through the shared coalescing cache. The value is built by the
2491 // same `tree_snapshot` the `tree` op runs, so a one-shot fetch and this
2492 // live push agree byte-for-byte — but here it is built once per tick and
2493 // shared across every subscriber rather than rebuilt per stream (#1303).
2494 self.cache.snapshot().await
2495 }
2496}
2497
2498/// A coalescing cache for the global tree snapshot (#1303).
2499///
2500/// Every open VS Code window holds one persistent [`WorktreesStream`], and the
2501/// server re-samples each on its own `STREAM_TICK` and on every registry change
2502/// — so with N windows the *identical* global tree was being built N times per
2503/// tick. This cache collapses that to **one** build: all streams share it, and
2504/// it rebuilds at most once per `ttl` (the stream tick) per registry
2505/// change-generation.
2506///
2507/// Two conditions gate reuse, and **both** must hold, so freshness is preserved
2508/// exactly as before:
2509/// - the registry's [`change_generation`](WorktreesRegistry::change_generation)
2510/// still matches — a `register`/`unregister`/toggle bumps it and forces a
2511/// fresh build, so subscribers never see a stale visible set; and
2512/// - the cached value is younger than `ttl` — so a pure on-disk git change (a
2513/// branch switch, new commits), which fires no registry event, still surfaces
2514/// within one tick.
2515///
2516/// Concurrency is single-flight: the `.await`-held [`AsyncMutex`] serializes
2517/// callers, so a burst of N streams waking on the same tick/change performs one
2518/// build while the rest wait and read the shared result. The one-shot `tree` op
2519/// bypasses this and computes fresh — it is a rare manual refresh, not part of
2520/// the per-tick fan-out.
2521struct TreeSnapshotCache {
2522 /// The registry every snapshot is built from, and whose change-generation
2523 /// gates cache reuse.
2524 registry: Arc<WorktreesRegistry>,
2525 /// PR badges folded onto each worktree as the snapshot is built (#1337).
2526 /// Written by the background poller; read here. A miss simply omits `pr`.
2527 pr_cache: Arc<PrStatusCache>,
2528 /// How long a built snapshot stays fresh before a tick-driven read rebuilds
2529 /// it. Defaults to the server's `STREAM_TICK` (via [`new`](Self::new)) so the
2530 /// coalesced build runs at most once per tick; tests inject a shorter value.
2531 ttl: Duration,
2532 /// The single-flight guard and cached result. A `tokio` mutex (not `std`)
2533 /// because it is deliberately held across the `.await` of the git
2534 /// enumeration, so concurrent callers serialize onto one build rather than
2535 /// each computing their own.
2536 state: AsyncMutex<Option<CachedTree>>,
2537 /// How many times the tree was actually (re)built — so tests can assert the
2538 /// coalescing collapses an N-stream burst into one build. Cheap and always
2539 /// maintained; only read under `#[cfg(test)]`.
2540 computes: AtomicU64,
2541}
2542
2543/// One cached tree snapshot: the shared value plus the two freshness stamps
2544/// [`TreeSnapshotCache`] checks before reusing it.
2545struct CachedTree {
2546 /// The registry change-generation captured *before* the build, so a change
2547 /// racing the build advances the generation and the next read rebuilds
2548 /// (conservative: it may rebuild once needlessly, but never serves stale).
2549 generation: u64,
2550 /// When the value was built, for the `ttl` staleness check.
2551 computed_at: Instant,
2552 /// The already-built `{ repos, show_closed }` snapshot, fanned out to every
2553 /// subscriber by cloning the `Arc`'s inner value.
2554 value: Arc<Value>,
2555}
2556
2557impl TreeSnapshotCache {
2558 /// Creates a cache over `registry` with the default TTL — the server's
2559 /// [`stream_tick`](crate::daemon::server::stream_tick), so the coalesced
2560 /// build runs at most once per tick.
2561 fn new(registry: Arc<WorktreesRegistry>, pr_cache: Arc<PrStatusCache>) -> Self {
2562 Self::with_ttl(registry, pr_cache, crate::daemon::server::stream_tick())
2563 }
2564
2565 /// Creates a cache with an explicit `ttl`, for tests that need a short (or
2566 /// long) freshness window without waiting a real tick.
2567 fn with_ttl(
2568 registry: Arc<WorktreesRegistry>,
2569 pr_cache: Arc<PrStatusCache>,
2570 ttl: Duration,
2571 ) -> Self {
2572 Self {
2573 registry,
2574 pr_cache,
2575 ttl,
2576 state: AsyncMutex::new(None),
2577 computes: AtomicU64::new(0),
2578 }
2579 }
2580
2581 /// The current tree snapshot, built at most once per `ttl` per registry
2582 /// change-generation and shared across all callers. See the type docs for
2583 /// the freshness and single-flight semantics.
2584 async fn snapshot(&self) -> Value {
2585 // Hold the lock across the whole check-and-build so concurrent callers
2586 // serialize onto one build (single-flight); reading the generation here
2587 // (before the build) means a change racing the build forces the *next*
2588 // read to rebuild rather than serving this now-stale value.
2589 let mut state = self.state.lock().await;
2590 let generation = self.registry.change_generation();
2591 // Reuse the cached value only while it matches the current generation
2592 // *and* is within the TTL; either failing forces a rebuild.
2593 let fresh = state.as_ref().and_then(|cached| {
2594 (cached.generation == generation && cached.computed_at.elapsed() < self.ttl)
2595 .then(|| Arc::clone(&cached.value))
2596 });
2597 let value = if let Some(value) = fresh {
2598 value
2599 } else {
2600 let value = Arc::new(tree_snapshot(&self.registry, self.pr_cache.clone()).await);
2601 self.computes.fetch_add(1, Ordering::Relaxed);
2602 *state = Some(CachedTree {
2603 generation,
2604 computed_at: Instant::now(),
2605 value: Arc::clone(&value),
2606 });
2607 value
2608 };
2609 // Release the lock before the (deeper) clone of the shared value out.
2610 drop(state);
2611 (*value).clone()
2612 }
2613
2614 /// How many times the tree was actually built — the coalescing assertion in
2615 /// tests (N reads within one tick/generation should build once).
2616 #[cfg(test)]
2617 fn compute_count(&self) -> u64 {
2618 self.computes.load(Ordering::Relaxed)
2619 }
2620}
2621
2622/// Builds the `{ repos, show_closed }` snapshot shared by the `tree` op and the
2623/// `subscribe` stream, so the two never drift (#1301). Two cheap registry locks
2624/// (the seed folders to derive repos from, and the live windows to join on) and
2625/// a lock-free read of the toggle, then the git enumeration/enrichment off the
2626/// lock on a blocking thread inside [`tree_repos`].
2627async fn tree_snapshot(registry: &WorktreesRegistry, pr_cache: Arc<PrStatusCache>) -> Value {
2628 let folders = registry.open_folders();
2629 let windows = registry.list();
2630 let show_closed = registry.show_closed();
2631 let enabled_polling = registry.enabled_polling_repos();
2632 json!({
2633 "repos": tree_repos(folders, windows, pr_cache, enabled_polling).await,
2634 "show_closed": show_closed,
2635 })
2636}
2637
2638/// A short human name for a window: its repo, else its first folder's basename,
2639/// else a placeholder.
2640fn display_name(entry: &WindowEntry) -> String {
2641 if let Some(repo) = &entry.repo {
2642 return repo.clone();
2643 }
2644 if let Some(folder) = entry.folders.first() {
2645 return folder.file_name().map_or_else(
2646 || folder.display().to_string(),
2647 |n| n.to_string_lossy().into_owned(),
2648 );
2649 }
2650 "(no folder)".to_string()
2651}
2652
2653/// Separator between the repo name and branch for a normal working tree.
2654const REPO_SEP: char = '·';
2655/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
2656/// line is distinguishable at a glance from its parent repo's main checkout.
2657const WORKTREE_SEP: char = '⑂';
2658
2659/// The full tray item list for a window set: the "No open windows" placeholder
2660/// when empty, else one line per window via [`window_menu_items`]. Does the git
2661/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
2662/// background refresh task — and inline only as a cold-start fallback in `menu`.
2663fn menu_items_for(
2664 entries: &[WindowEntry],
2665 rate_limit: Option<&RateLimitSnapshot>,
2666) -> Vec<MenuItem> {
2667 let mut items = Vec::new();
2668 // Prepend the GitHub rate-limit reading (#1375) as a non-clickable status line
2669 // above the windows, so an approaching exhaustion is visible in the tray before
2670 // it bites. Absent (unpolled `gh`, or no resources) → no line and no separator.
2671 if let Some(label) = rate_limit.map(RateLimitSnapshot::tray_label) {
2672 if !label.is_empty() {
2673 items.push(MenuItem::Label(label));
2674 items.push(MenuItem::Separator);
2675 }
2676 }
2677 if entries.is_empty() {
2678 items.push(MenuItem::Label("No open windows".to_string()));
2679 } else {
2680 items.extend(window_menu_items(entries));
2681 }
2682 items
2683}
2684
2685/// Builds the tray items for a non-empty window list: **one clickable line per
2686/// window** whose label carries the live git state and whose click focuses that
2687/// window. A window with no workspace folder has nothing for `code` to open, so
2688/// it stays a non-clickable status line. The labels read each worktree from disk
2689/// (via [`window_label`]) — cheap for a realistic window count and consistent
2690/// with reap-on-read.
2691fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
2692 entries
2693 .iter()
2694 .map(|entry| {
2695 let label = window_label(entry);
2696 if entry.folders.is_empty() {
2697 MenuItem::Label(label)
2698 } else {
2699 MenuItem::Action(MenuAction {
2700 id: format!("focus:{}", entry.key),
2701 label,
2702 enabled: true,
2703 })
2704 }
2705 })
2706 .collect()
2707}
2708
2709/// The tray label for one window: the **main repository** name, then live branch
2710/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
2711/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
2712/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
2713/// that is not a repo falls back to its reported title.
2714fn window_label(entry: &WindowEntry) -> String {
2715 let status = entry
2716 .folders
2717 .first()
2718 .map(|folder| git_status(folder))
2719 .unwrap_or_default();
2720 // Prefer the git-derived main repo so a linked worktree names its parent
2721 // repository rather than its worktree-folder basename.
2722 let name = status
2723 .main_repo
2724 .clone()
2725 .unwrap_or_else(|| display_name(entry));
2726 if let Some(branch) = &status.branch {
2727 let sep = if status.is_worktree {
2728 WORKTREE_SEP
2729 } else {
2730 REPO_SEP
2731 };
2732 return match sync_indicator(status.ahead, status.behind) {
2733 Some(sync) => format!("{name} {sep} {branch} {sync}"),
2734 None => format!("{name} {sep} {branch}"),
2735 };
2736 }
2737 // No git branch (not a repo / detached): fall back to the reported title.
2738 match &entry.title {
2739 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
2740 _ => name,
2741 }
2742}
2743
2744/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
2745/// has no upstream to compare against.
2746fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
2747 match (ahead, behind) {
2748 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
2749 _ => None,
2750 }
2751}
2752
2753/// Well-known absolute locations for the VS Code launcher, tried in order so a
2754/// daemon running under launchd (with a minimal `PATH`) still finds it.
2755const CODE_BINARY_CANDIDATES: &[&str] = &[
2756 "/usr/local/bin/code",
2757 "/opt/homebrew/bin/code",
2758 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
2759 "/usr/bin/code",
2760];
2761
2762/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
2763/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`]. Shared
2764/// with the sessions service's tray "focus" action, which resolves a session to
2765/// its VS Code window folder and opens it through this same guarded launcher.
2766pub(crate) fn focus_window(folder: &Path) -> Result<()> {
2767 focus_window_with(&resolve_code_binary(), folder)
2768}
2769
2770/// Spawns `program` on `folder` after validating the folder. Split out from
2771/// [`focus_window`] so the validation and spawn paths are testable with an
2772/// explicit launcher (no environment or installed-editor dependency).
2773///
2774/// Best-effort and non-blocking: the spawned child is reaped on a detached
2775/// thread so a long-lived daemon does not accumulate zombies one per focus.
2776fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
2777 // The tray path passes an absolute workspace folder, but the socket `open`
2778 // op (#1266) passes an arbitrary client-supplied path, so this guard is a
2779 // real check there, not just an assertion: requiring an absolute path also
2780 // rules out a `-`-leading path being parsed by `code` as a flag.
2781 if !folder.is_absolute() {
2782 bail!(
2783 "refusing to focus a non-absolute folder path: {}",
2784 folder.display()
2785 );
2786 }
2787 if !folder.is_dir() {
2788 bail!("worktree folder no longer exists: {}", folder.display());
2789 }
2790 // Detach the launcher's stdio so its output never interleaves into the
2791 // long-lived daemon's own stdout/stderr (or the test harness's).
2792 let child = Command::new(program)
2793 .arg(folder)
2794 .stdin(Stdio::null())
2795 .stdout(Stdio::null())
2796 .stderr(Stdio::null())
2797 .spawn()
2798 .with_context(|| {
2799 format!(
2800 "failed to launch `{}` to focus {}",
2801 program.display(),
2802 folder.display()
2803 )
2804 })?;
2805 // Reap the child without blocking so it never lingers as a zombie.
2806 std::thread::spawn(move || {
2807 let mut child = child;
2808 let _ = child.wait();
2809 });
2810 Ok(())
2811}
2812
2813/// Resolves the VS Code launcher from the real environment: the
2814/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
2815/// `code` on `PATH`. The pure resolution logic lives in
2816/// [`resolve_code_binary_from`] for testing.
2817fn resolve_code_binary() -> PathBuf {
2818 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
2819}
2820
2821/// Pure launcher resolution: `env_override` wins; otherwise the first existing
2822/// `candidate`; otherwise bare `code`.
2823fn resolve_code_binary_from(
2824 env_override: Option<std::ffi::OsString>,
2825 candidates: &[&str],
2826) -> PathBuf {
2827 if let Some(path) = env_override {
2828 return PathBuf::from(path);
2829 }
2830 for candidate in candidates {
2831 let path = Path::new(candidate);
2832 if path.exists() {
2833 return path.to_path_buf();
2834 }
2835 }
2836 PathBuf::from("code")
2837}
2838
2839// --- Close op (#1277) --------------------------------------------------------
2840
2841/// The `close` op payload: close a worktree's window and (for a linked worktree)
2842/// delete it. Symmetric to `open`, but destructive, so it carries the
2843/// two-phase-confirm and self-close routing fields.
2844#[derive(Debug, Clone, Deserialize)]
2845struct CloseRequest {
2846 /// Absolute path of the target worktree's working directory.
2847 path: PathBuf,
2848 /// The requesting window's key, so a self-close (`requester_key` owns the
2849 /// target) removes-then-replies and lets the extension close its own window,
2850 /// rather than waiting on a window that is blocked awaiting this reply.
2851 #[serde(default)]
2852 requester_key: Option<String>,
2853 /// Whether to **delete** the worktree (linked "Close Worktree") rather than
2854 /// only close its window (main "Close Window"). A delete is refused on the
2855 /// main working tree regardless of this flag.
2856 #[serde(default)]
2857 remove: bool,
2858 /// Set on the phase-2 execute call. Absent/false with `remove:true` is the
2859 /// phase-1, side-effect-free safety check; ignored for `remove:false`.
2860 #[serde(default)]
2861 confirmed: bool,
2862}
2863
2864/// One risk or informational note in a [`SafetyReport`]: a machine-readable
2865/// `kind` and a human-readable `detail`. Shared by both the blocking `risks`
2866/// (data would be lost) and the non-blocking `info` (context, e.g. unpushed
2867/// commits that survive because the branch is kept).
2868#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2869struct Note {
2870 /// A stable machine slug for the condition (e.g. `dirty`, `untracked`).
2871 kind: String,
2872 /// A human-readable one-line explanation for the confirm dialog.
2873 detail: String,
2874}
2875
2876impl Note {
2877 fn new(kind: &str, detail: impl Into<String>) -> Self {
2878 Self {
2879 kind: kind.to_string(),
2880 detail: detail.into(),
2881 }
2882 }
2883}
2884
2885/// Joins a set of [`Note`]s' machine slugs into a compact `a,b` string (empty →
2886/// `-`) for a single structured log field. Used by the `close` op's audit lines
2887/// (#1364) so a verdict's risk kinds ride one field rather than a `Debug` dump.
2888fn note_kinds(notes: &[Note]) -> String {
2889 if notes.is_empty() {
2890 return "-".to_string();
2891 }
2892 notes
2893 .iter()
2894 .map(|n| n.kind.as_str())
2895 .collect::<Vec<_>>()
2896 .join(",")
2897}
2898
2899/// Whether a `close` execute is a **self-close**: the requesting window owns the
2900/// target, so it acts on our `ok:true` reply and never rides the cross-window
2901/// signal. Split out as a pure predicate so the routing decision the audit line
2902/// (#1364) reports is unit-testable.
2903fn is_self_close(requester_key: Option<&str>, open_windows: &[(String, usize)]) -> bool {
2904 requester_key.is_some_and(|rk| open_windows.iter().any(|(k, _)| k == rk))
2905}
2906
2907/// Logs a `close`-op failure at ERROR before propagating it. The phase-1/phase-2
2908/// audit lines sit *past* the fallible `git_safety` / removal calls, so without
2909/// this a failed safety check (a non-git-worktree target) or a panicked blocking
2910/// task would early-return invisibly — the exact blind spot #1364 closes. Returns
2911/// the error unchanged so callers keep using `?`.
2912fn log_close_error(path: &Path, phase: &str, err: anyhow::Error) -> anyhow::Error {
2913 tracing::error!(
2914 path = %path.display(),
2915 "worktrees close: {phase} failed: {err:#}"
2916 );
2917 err
2918}
2919
2920/// Logs the outcome of a linked-worktree removal and maps it to the `close`
2921/// reply. Split out of [`WorktreesService::close`] so the destructive op's audit
2922/// line (#1364) is unit-testable without a tokio runtime or the `spawn_blocking`
2923/// the real prune runs behind. A successful prune is INFO; a failure is WARN
2924/// (a destructive op that did not complete) and propagates the error.
2925fn log_and_map_removal(path: &Path, removed: Result<()>) -> Result<Value> {
2926 match removed {
2927 Ok(()) => {
2928 tracing::info!(
2929 path = %path.display(),
2930 "worktrees close: linked worktree pruned"
2931 );
2932 Ok(json!({ "removed": true }))
2933 }
2934 Err(err) => {
2935 tracing::warn!(
2936 path = %path.display(),
2937 "worktrees close: worktree prune failed: {err:#}"
2938 );
2939 Err(err)
2940 }
2941 }
2942}
2943
2944/// Emits the phase-1 audit line for a `close` safety check (#1364): the target,
2945/// the owning window key (if any), the open flag, and the deletability verdict
2946/// with the blocking risk kinds. Split out so the audit line is unit-testable off
2947/// the runtime — a `tracing` event fired right after the `git_safety`
2948/// `spawn_blocking` is not reliably captured under the parallel suite.
2949fn log_safety_check(path: &Path, window_key: Option<&str>, git: &GitSafety, open: bool) {
2950 tracing::info!(
2951 path = %path.display(),
2952 window_key = window_key.unwrap_or("-"),
2953 removable = git.removable,
2954 is_main = git.is_main,
2955 open,
2956 risks = %note_kinds(&git.risks),
2957 "worktrees close: safety check"
2958 );
2959}
2960
2961/// Emits the phase-2 audit line for a `close` execute (#1364): the requesting
2962/// window key and the routing decision (self-close vs. how many cross-window
2963/// targets are being signalled), logged before the wait so it is auditable even
2964/// if that wait then hangs. Sync so it is unit-testable off the runtime.
2965fn log_executing(
2966 path: &Path,
2967 requester: Option<&str>,
2968 remove: bool,
2969 self_close: bool,
2970 cross_window: usize,
2971) {
2972 tracing::info!(
2973 path = %path.display(),
2974 requester = requester.unwrap_or("-"),
2975 remove,
2976 self_close,
2977 cross_window,
2978 "worktrees close: executing"
2979 );
2980}
2981
2982/// Emits the phase-2 audit WARN when a `close` execute aborts because a signalled
2983/// window never closed (#1364): the op leaves the worktree intact. Sync so it is
2984/// unit-testable off the runtime.
2985fn log_close_abort(path: &Path, err: &anyhow::Error) {
2986 tracing::warn!(
2987 path = %path.display(),
2988 "worktrees close: aborted — signalled window(s) did not close: {err:#}"
2989 );
2990}
2991
2992/// Emits the phase-2 audit line for a non-destructive `close` — "Close Window":
2993/// the window is closed and nothing is deleted (#1364). Sync so it is
2994/// unit-testable off the runtime.
2995fn log_window_closed(path: &Path) {
2996 tracing::info!(
2997 path = %path.display(),
2998 "worktrees close: window closed, no removal"
2999 );
3000}
3001
3002/// The phase-1 safety report the extension reads to decide whether to prompt.
3003/// `removable && risks.is_empty()` → proceed with **no** dialog; any `risks`
3004/// entry → show a modal confirm listing them.
3005#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3006struct SafetyReport {
3007 /// Whether the target is a deletable (linked) worktree at all — `false` for
3008 /// the main working tree, which the daemon never removes.
3009 removable: bool,
3010 /// Whether the target is the repository's main working tree.
3011 is_main: bool,
3012 /// Whether a live VS Code window currently has the target open.
3013 open: bool,
3014 /// The owning window's key, when `open` (the first, for the wait/close).
3015 #[serde(skip_serializing_if = "Option::is_none")]
3016 window_key: Option<String>,
3017 /// How many workspace folders the owning window has — so the extension can
3018 /// warn "this window has N folders open; all will close" (failure mode #10).
3019 window_folder_count: usize,
3020 /// Conditions that would lose data on removal; a non-empty list forces a
3021 /// confirm dialog.
3022 risks: Vec<Note>,
3023 /// Non-blocking context shown for awareness (e.g. unpushed commits that
3024 /// survive because the branch is kept).
3025 info: Vec<Note>,
3026}
3027
3028/// The git-only half of the safety check, before the registry's open-window
3029/// facts are folded in. Pure disk I/O; computed on a blocking thread.
3030#[derive(Debug, Clone, PartialEq, Eq)]
3031struct GitSafety {
3032 is_main: bool,
3033 removable: bool,
3034 risks: Vec<Note>,
3035 info: Vec<Note>,
3036}
3037
3038/// Live windows (key, workspace-folder count) that currently have `path` open,
3039/// matched by canonicalized path so a symlinked or `..`-laden report still
3040/// joins. Disk I/O (canonicalization), so it runs on a blocking thread.
3041fn windows_with_path(entries: &[WindowEntry], path: &Path) -> Vec<(String, usize)> {
3042 let target = canonical(path);
3043 entries
3044 .iter()
3045 .filter(|e| e.folders.iter().any(|f| canonical(f) == target))
3046 .map(|e| (e.key.clone(), e.folders.len()))
3047 .collect()
3048}
3049
3050/// How long the execute phase waits for a signalled window to close
3051/// (`unregister`) before giving up. Deliberately generous against the ~10s
3052/// heartbeat interval the close directive rides — a window may have just
3053/// heartbeated, so the directive is only picked up on the *next* one — plus the
3054/// window's own close/save latency. The keyed-push responsiveness upgrade
3055/// (#1277 fast-follow) removes this wait entirely.
3056const CLOSE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
3057
3058/// How often the execute phase re-checks whether the signalled windows have
3059/// unregistered.
3060const CLOSE_WAIT_POLL: Duration = Duration::from_millis(250);
3061
3062/// Waits up to `timeout` for every window *other than* `requester` that has
3063/// `path` open to unregister (close), polling the live registry every `poll`.
3064/// A window whose `last_seen` has already gone stale is reaped by `list()` and
3065/// so counts as closed. Returns an error naming the still-open windows on
3066/// timeout, so the caller can surface "window did not close" and leave the
3067/// worktree untouched (failure modes #4/#5).
3068async fn await_windows_closed(
3069 registry: &WorktreesRegistry,
3070 path: &Path,
3071 requester: Option<&str>,
3072 timeout: Duration,
3073 poll: Duration,
3074) -> Result<()> {
3075 let deadline = std::time::Instant::now() + timeout;
3076 loop {
3077 // The registry read is cheap CPU, but the path canonicalization in
3078 // `windows_with_path` is disk I/O — do the whole check on a blocking
3079 // thread, never on the async worker.
3080 let entries = registry.list();
3081 let path = path.to_path_buf();
3082 let requester = requester.map(str::to_string);
3083 let remaining: Vec<String> = tokio::task::spawn_blocking(move || {
3084 windows_with_path(&entries, &path)
3085 .into_iter()
3086 .map(|(k, _)| k)
3087 .filter(|k| requester.as_deref() != Some(k))
3088 .collect()
3089 })
3090 .await
3091 .unwrap_or_default();
3092
3093 if remaining.is_empty() {
3094 return Ok(());
3095 }
3096 if std::time::Instant::now() >= deadline {
3097 bail!("window(s) did not close in time: {}", remaining.join(", "));
3098 }
3099 tokio::time::sleep(poll).await;
3100 }
3101}
3102
3103/// Computes the [`GitSafety`] of a worktree at `path`: whether it is the main
3104/// working tree (never removable) and, for a linked worktree, what a removal
3105/// would lose. Best-effort per-check but the overall open must succeed — a path
3106/// that is not a git worktree is a hard error (we refuse to delete an unknown
3107/// directory). A path that no longer exists is treated as an already-removed
3108/// linked worktree so the idempotent execute path can proceed with no dialog.
3109fn git_safety(path: &Path) -> Result<GitSafety> {
3110 if !path.exists() {
3111 return Ok(GitSafety {
3112 is_main: false,
3113 removable: true,
3114 risks: vec![],
3115 info: vec![Note::new("already-removed", "worktree no longer exists")],
3116 });
3117 }
3118 let repo = Repository::open(path)
3119 .with_context(|| format!("not a git worktree: {}", path.display()))?;
3120 // The one structural fact deletability keys off — never the branch name.
3121 if !repo.is_worktree() {
3122 return Ok(GitSafety {
3123 is_main: true,
3124 removable: false,
3125 risks: vec![],
3126 info: vec![Note::new(
3127 "main-working-tree",
3128 "the repository's main working tree is never deleted",
3129 )],
3130 });
3131 }
3132
3133 let mut risks = Vec::new();
3134 let mut info = Vec::new();
3135
3136 let (dirty, untracked) = count_dirty_untracked(&repo);
3137 if dirty > 0 {
3138 risks.push(Note::new(
3139 "dirty",
3140 format!("{dirty} modified tracked file(s) would be lost"),
3141 ));
3142 }
3143 if untracked > 0 {
3144 risks.push(Note::new(
3145 "untracked",
3146 format!("{untracked} untracked file(s) would be lost"),
3147 ));
3148 }
3149
3150 // An in-progress rebase/merge/cherry-pick etc. is lost on removal.
3151 let state = repo.state();
3152 if state != RepositoryState::Clean {
3153 risks.push(Note::new(
3154 "in-progress",
3155 format!("an in-progress {state:?} operation would be lost"),
3156 ));
3157 }
3158
3159 // Commits reachable only from a detached HEAD are GC'd once the worktree —
3160 // and its HEAD ref — are gone. A HEAD still reachable from any ref (a branch
3161 // or tag) loses nothing, so it is not flagged.
3162 if repo.head_detached().unwrap_or(false) {
3163 let lost = unreachable_commit_count(&repo).unwrap_or(0);
3164 if lost > 0 {
3165 risks.push(Note::new(
3166 "unreachable-commits",
3167 format!("{lost} commit(s) on a detached HEAD will be permanently lost"),
3168 ));
3169 }
3170 }
3171
3172 // Unpushed commits on a *named* branch survive: removal never deletes the
3173 // branch. Informational only — it must not block or prompt.
3174 if let Some(ahead) = current_branch_ahead(&repo) {
3175 if ahead > 0 {
3176 info.push(Note::new(
3177 "unpushed",
3178 format!("{ahead} unpushed commit(s) on the branch (kept — the branch survives)"),
3179 ));
3180 }
3181 }
3182
3183 Ok(GitSafety {
3184 is_main: false,
3185 removable: true,
3186 risks,
3187 info,
3188 })
3189}
3190
3191/// Counts a worktree's `(dirty tracked, untracked)` files. Tracked covers any
3192/// staged or unstaged modification (including conflicts and deletions);
3193/// untracked is `WT_NEW`. `.gitignore`d files are excluded — they are
3194/// regenerable and must not force a prompt — via `include_ignored(false)`, so no
3195/// status entry ever carries the `IGNORED` bit. A failed status read degrades to
3196/// `(0, 0)` rather than sinking the whole safety check.
3197fn count_dirty_untracked(repo: &Repository) -> (usize, usize) {
3198 let mut opts = StatusOptions::new();
3199 opts.include_untracked(true)
3200 .recurse_untracked_dirs(true)
3201 .include_ignored(false)
3202 .exclude_submodules(true);
3203 let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
3204 return (0, 0);
3205 };
3206 // Any staged or unstaged change to a tracked path (WT_NEW is untracked, so
3207 // it is deliberately excluded from this mask).
3208 let tracked = Status::INDEX_NEW
3209 | Status::INDEX_MODIFIED
3210 | Status::INDEX_DELETED
3211 | Status::INDEX_RENAMED
3212 | Status::INDEX_TYPECHANGE
3213 | Status::WT_MODIFIED
3214 | Status::WT_DELETED
3215 | Status::WT_TYPECHANGE
3216 | Status::WT_RENAMED
3217 | Status::CONFLICTED;
3218 let mut dirty = 0;
3219 let mut untracked = 0;
3220 for entry in statuses.iter() {
3221 let s = entry.status();
3222 if s.contains(Status::WT_NEW) {
3223 untracked += 1;
3224 }
3225 if s.intersects(tracked) {
3226 dirty += 1;
3227 }
3228 }
3229 (dirty, untracked)
3230}
3231
3232/// Counts commits reachable from the (detached) HEAD but from no other ref —
3233/// the commits git would garbage-collect once the worktree's HEAD is gone.
3234/// `None` if HEAD or the revwalk cannot be resolved. The literal `HEAD` ref is
3235/// skipped (hiding it would hide the very commits we are counting); every real
3236/// branch/tag/remote ref is hidden, so a tip that any branch also points at
3237/// yields `0` (nothing is actually lost).
3238fn unreachable_commit_count(repo: &Repository) -> Option<usize> {
3239 let head_oid = repo.head().ok()?.target()?;
3240 let mut walk = repo.revwalk().ok()?;
3241 walk.push(head_oid).ok()?;
3242 for reference in repo.references().ok()? {
3243 let Ok(reference) = reference else { continue };
3244 // Skip the literal HEAD ref — hiding it would hide the very commits we
3245 // are counting; every real branch/tag/remote ref is hidden below.
3246 if matches!(reference.name(), Ok("HEAD")) {
3247 continue;
3248 }
3249 if let Some(oid) = reference.target() {
3250 let _ = walk.hide(oid);
3251 }
3252 }
3253 Some(walk.flatten().count())
3254}
3255
3256/// Commits the worktree's current branch is ahead of its upstream, or `None`
3257/// when HEAD is detached or the branch tracks no upstream. Reuses
3258/// [`upstream_ahead_behind`]; only the ahead count matters here (unpushed work).
3259fn current_branch_ahead(repo: &Repository) -> Option<usize> {
3260 let head = repo.head().ok()?;
3261 if !head.is_branch() {
3262 return None;
3263 }
3264 let branch = git2::Branch::wrap(head);
3265 upstream_ahead_behind(repo, &branch).map(|(ahead, _behind)| ahead)
3266}
3267
3268/// Resolves the linked worktree whose working directory canonicalizes to
3269/// `target` to its registered name in `main_repo`. Errors when `target` is not
3270/// one of the repo's worktrees — the defensive guard against removing a path
3271/// that opened as a worktree but is not enumerated. Split out so that guard is
3272/// unit-testable without corrupting git's worktree admin state.
3273fn worktree_name_for_path(main_repo: &Repository, target: &Path) -> Result<String> {
3274 let names = main_repo.worktrees()?;
3275 names
3276 .iter()
3277 .flatten() // Result<Option<&str>, _> → Option<&str> (drop per-name errors)
3278 .flatten() // Option<&str> → &str (drop non-UTF-8 names)
3279 .find(|name| {
3280 main_repo
3281 .find_worktree(name)
3282 .is_ok_and(|wt| canonical(wt.path()) == target)
3283 })
3284 .map(str::to_string)
3285 .ok_or_else(|| {
3286 anyhow!(
3287 "worktree {} is not registered in {}",
3288 target.display(),
3289 main_repo.path().display()
3290 )
3291 })
3292}
3293
3294/// Backoff delays between recursive-removal retries (#1315). A concurrent
3295/// writer — a just-closed window's language server (Metals/Bloop) or
3296/// `rust-analyzer`/`cargo` still flushing build artifacts into `target/` — can
3297/// create a file between our directory scan and its `rmdir`, making the removal
3298/// fail with `ENOTEMPTY` ("Directory not empty"). Each retry re-sweeps and
3299/// waits longer, giving the winding-down process time to quiesce. Total wait
3300/// ~2.75s across four retries; the window teardown the caller already waited on
3301/// dominates it.
3302const WORKTREE_RMDIR_BACKOFF: &[Duration] = &[
3303 Duration::from_millis(250),
3304 Duration::from_millis(500),
3305 Duration::from_secs(1),
3306 Duration::from_secs(1),
3307];
3308
3309/// Whether `e` is the transient "directory re-populated under us" race we retry
3310/// (see [`WORKTREE_RMDIR_BACKOFF`]) rather than a hard failure (permission
3311/// denied, read-only filesystem) we must surface immediately. Matches the raw
3312/// errno — `std::io::ErrorKind::DirectoryNotEmpty` is only stable from Rust 1.83,
3313/// past our MSRV — including the `EEXIST`/`EBUSY` siblings libgit2 lumps in.
3314fn is_transient_rmdir_error(e: &std::io::Error) -> bool {
3315 matches!(
3316 e.raw_os_error(),
3317 Some(nix::libc::ENOTEMPTY | nix::libc::EEXIST | nix::libc::EBUSY)
3318 )
3319}
3320
3321/// Recursively removes `dir`, retrying on the transient concurrent-writer race
3322/// (see [`is_transient_rmdir_error`]) and treating an already-absent directory
3323/// as success. Non-transient errors surface immediately with the original
3324/// message. Runs on a blocking thread (called only from [`remove_worktree`], via
3325/// `spawn_blocking`), so the between-retry `sleep` is fine.
3326fn remove_dir_all_retrying(dir: &Path) -> Result<()> {
3327 remove_dir_all_retrying_with(dir, WORKTREE_RMDIR_BACKOFF, || std::fs::remove_dir_all(dir))
3328}
3329
3330/// [`remove_dir_all_retrying`] with the schedule and the removal itself injected.
3331/// Provoking the real race requires a concurrent writer to lose a timing window,
3332/// so only an injected sequence of errors can drive every branch of the loop —
3333/// exhausting the backoff especially — deterministically and without sleeping out
3334/// the production schedule.
3335fn remove_dir_all_retrying_with(
3336 dir: &Path,
3337 backoff: &[Duration],
3338 mut remove: impl FnMut() -> std::io::Result<()>,
3339) -> Result<()> {
3340 let mut backoff = backoff.iter();
3341 loop {
3342 match remove() {
3343 Ok(()) => return Ok(()),
3344 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3345 Err(e) => {
3346 if is_transient_rmdir_error(&e) {
3347 if let Some(delay) = backoff.next() {
3348 std::thread::sleep(*delay);
3349 continue;
3350 }
3351 }
3352 return Err(e).with_context(|| {
3353 format!("failed to remove worktree directory {}", dir.display())
3354 });
3355 }
3356 }
3357 }
3358}
3359
3360/// Whether `path` is a **half-removed** linked worktree: its `.git` gitlink
3361/// still points at an admin directory a prior failed removal already deleted.
3362/// libgit2's combined prune deletes the admin metadata *before* it rmdirs the
3363/// working tree, so a working-tree rmdir failure (#1315) leaves exactly this
3364/// orphan — the directory on disk with a dangling gitlink, no longer tracked by
3365/// git. Safe to delete outright: a live worktree's gitlink resolves (its repo
3366/// opens) and a normal checkout has a `.git` *directory*, so this matches
3367/// neither.
3368fn is_orphaned_worktree(path: &Path) -> bool {
3369 // `read_to_string` fails on a `.git` directory (a normal checkout), so only
3370 // a linked worktree's gitlink file gets past here.
3371 let Ok(contents) = std::fs::read_to_string(path.join(".git")) else {
3372 return false;
3373 };
3374 let Some(admin) = contents.strip_prefix("gitdir:").map(str::trim) else {
3375 return false;
3376 };
3377 let admin = Path::new(admin);
3378 // A linked-worktree admin path (`…/worktrees/<name>`) whose target is gone.
3379 admin.components().any(|c| c.as_os_str() == "worktrees") && !admin.exists()
3380}
3381
3382/// Removes a **linked** worktree at `path` via `git2` (no shell — avoiding the
3383/// daemon-`PATH` problem the launcher fights): deletes both the checked-out
3384/// directory and the admin metadata. Refuses the main working tree (the
3385/// defensive backstop behind the UI gating) and a locked worktree (surfacing
3386/// "unlock first" rather than forcing past the lock). Idempotent: an
3387/// already-removed path is a success.
3388///
3389/// The working tree is removed **first** (retrying to absorb the
3390/// concurrent-writer race, #1315), and only then is the admin metadata pruned.
3391/// This is deliberately the reverse of libgit2's combined
3392/// `prune(working_tree: true)`, which deletes the admin dir first and, when the
3393/// working-tree rmdir then fails, leaves a **half-removed orphan** git no longer
3394/// tracks (and which a naive prune-retry cannot recover, since its admin gitdir
3395/// is already gone). Doing the directory first means a transient failure leaves
3396/// the worktree fully tracked and cleanly retryable; a pre-existing orphan from
3397/// the old ordering is detected and its leftover directory cleaned up directly.
3398fn remove_worktree(path: &Path) -> Result<()> {
3399 if !path.exists() {
3400 return Ok(());
3401 }
3402 let repo = match Repository::open(path) {
3403 Ok(repo) => repo,
3404 // Admin metadata already gone (a prior failed removal); git no longer
3405 // tracks this path, so no prune applies — just delete the leftover.
3406 Err(_) if is_orphaned_worktree(path) => return remove_dir_all_retrying(path),
3407 Err(e) => return Err(e).context(format!("not a git worktree: {}", path.display())),
3408 };
3409 if !repo.is_worktree() {
3410 bail!(
3411 "refusing to delete the main working tree: {}",
3412 path.display()
3413 );
3414 }
3415 // The Worktree handle lives on the *main* repo (the common dir's parent),
3416 // keyed by name; find it by matching the target path.
3417 let commondir = canonical(repo.commondir());
3418 let main_root = commondir
3419 .parent()
3420 .ok_or_else(|| anyhow!("no repository root for {}", path.display()))?
3421 .to_path_buf();
3422 // Drop the worktree-scoped handle before we delete its directory.
3423 drop(repo);
3424 let main_repo = Repository::open(&main_root)
3425 .with_context(|| format!("failed to open repository at {}", main_root.display()))?;
3426 let name = worktree_name_for_path(&main_repo, &canonical(path))?;
3427 let worktree = main_repo.find_worktree(&name)?;
3428
3429 // Never silently force past a lock (failure mode #6).
3430 if let WorktreeLockStatus::Locked(reason) = worktree.is_locked()? {
3431 let because = reason.map(|r| format!(" ({r})")).unwrap_or_default();
3432 bail!("worktree is locked{because}; unlock it first (git worktree unlock)");
3433 }
3434
3435 // Delete the checked-out directory ourselves, retrying past the
3436 // concurrent-writer race (#1315).
3437 remove_dir_all_retrying(path)?;
3438
3439 // The directory is gone; prune only the admin metadata. working_tree(false)
3440 // keeps git2 from re-attempting (and failing on) the now-absent directory;
3441 // valid(true) prunes even though the worktree was valid; locked stays false,
3442 // so a lock (re-checked above) is never forced.
3443 let mut opts = git2::WorktreePruneOptions::new();
3444 opts.valid(true).working_tree(false);
3445 worktree
3446 .prune(Some(&mut opts))
3447 .with_context(|| format!("failed to prune worktree metadata for {}", path.display()))?;
3448 Ok(())
3449}
3450
3451#[cfg(test)]
3452#[allow(clippy::unwrap_used, clippy::expect_used)]
3453mod tests {
3454 use super::*;
3455 use crate::test_support::shim::{shim_lock, write_exec_script};
3456 use std::sync::MutexGuard;
3457
3458 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
3459 json!({
3460 "key": key,
3461 "folders": [folder],
3462 "repo": repo,
3463 "title": format!("{key}-title"),
3464 "pid": 1234,
3465 })
3466 }
3467
3468 /// Pulls the `windows` array out of a `list`/`status` payload.
3469 fn windows_of(payload: &Value) -> &Vec<Value> {
3470 payload
3471 .get("windows")
3472 .and_then(Value::as_array)
3473 .expect("windows array")
3474 }
3475
3476 #[tokio::test]
3477 async fn name_and_unknown_op() {
3478 let svc = WorktreesService::new();
3479 assert_eq!(svc.name(), "worktrees");
3480 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
3481 }
3482
3483 #[tokio::test]
3484 async fn handle_routes_ops_and_shapes_payloads() {
3485 let svc = WorktreesService::new();
3486 // Empty to start.
3487 let payload = svc.handle("list", Value::Null).await.unwrap();
3488 assert_eq!(payload, json!({ "windows": [] }));
3489
3490 // register → { ok: true }, then it shows up in list.
3491 let reply = svc
3492 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3493 .await
3494 .unwrap();
3495 assert_eq!(reply, json!({ "ok": true }));
3496 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
3497 assert_eq!(windows.len(), 1);
3498 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
3499 assert!(windows[0].get("last_seen").is_some());
3500
3501 // heartbeat known/unknown.
3502 let known = svc
3503 .handle("heartbeat", json!({ "key": "w1" }))
3504 .await
3505 .unwrap();
3506 assert_eq!(known, json!({ "known": true }));
3507 let unknown = svc
3508 .handle("heartbeat", json!({ "key": "nope" }))
3509 .await
3510 .unwrap();
3511 assert_eq!(unknown, json!({ "known": false }));
3512
3513 // unregister removes, then repeats as a no-op success.
3514 let gone = svc
3515 .handle("unregister", json!({ "key": "w1" }))
3516 .await
3517 .unwrap();
3518 assert_eq!(gone, json!({ "removed": true }));
3519 let again = svc
3520 .handle("unregister", json!({ "key": "w1" }))
3521 .await
3522 .unwrap();
3523 assert_eq!(again, json!({ "removed": false }));
3524 }
3525
3526 #[tokio::test]
3527 async fn handle_rejects_missing_or_empty_key() {
3528 let svc = WorktreesService::new();
3529 // register validates a present, non-blank key.
3530 assert!(svc.handle("register", json!({})).await.is_err());
3531 assert!(svc
3532 .handle("register", json!({ "key": " " }))
3533 .await
3534 .is_err());
3535 // heartbeat/unregister require the key via `require_str`.
3536 assert!(svc.handle("heartbeat", json!({})).await.is_err());
3537 assert!(svc.handle("unregister", json!({})).await.is_err());
3538 }
3539
3540 #[test]
3541 fn display_name_prefers_repo_then_folder_basename() {
3542 let base = WindowEntry {
3543 key: "k".to_string(),
3544 folders: vec![PathBuf::from("/home/me/project")],
3545 repo: Some("my-repo".to_string()),
3546 title: None,
3547 pid: None,
3548 last_seen: Utc::now(),
3549 };
3550 assert_eq!(display_name(&base), "my-repo");
3551
3552 let no_repo = WindowEntry {
3553 repo: None,
3554 ..base.clone()
3555 };
3556 assert_eq!(display_name(&no_repo), "project");
3557
3558 let nothing = WindowEntry {
3559 repo: None,
3560 folders: vec![],
3561 ..base.clone()
3562 };
3563 assert_eq!(display_name(¬hing), "(no folder)");
3564
3565 // A folder with no basename (the filesystem root) falls back to its
3566 // displayed path rather than panicking or yielding an empty name.
3567 let rootish = WindowEntry {
3568 repo: None,
3569 folders: vec![PathBuf::from("/")],
3570 ..base
3571 };
3572 assert_eq!(display_name(&rootish), "/");
3573 }
3574
3575 #[test]
3576 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
3577 let now = Utc::now();
3578 let entries = vec![
3579 // A folderless window has nothing to focus, so it stays a plain
3580 // Label; a title equal to the name collapses to just the name. It
3581 // leads the list so the focus-action lookup below is exercised
3582 // against a leading non-Action item it has to skip.
3583 WindowEntry {
3584 key: "k2".to_string(),
3585 folders: vec![],
3586 repo: Some("solo".to_string()),
3587 title: Some("solo".to_string()),
3588 pid: None,
3589 last_seen: now,
3590 },
3591 // A folder-bearing, non-repo window: one clickable Action whose label
3592 // is the stats line ("name · title", since /tmp is not a git repo).
3593 WindowEntry {
3594 key: "k1".to_string(),
3595 folders: vec![PathBuf::from("/tmp/a")],
3596 repo: Some("repo".to_string()),
3597 title: Some("a branch".to_string()),
3598 pid: None,
3599 last_seen: now,
3600 },
3601 ];
3602 let items = window_menu_items(&entries);
3603 // Exactly one item per window — no duplicate label, no separator.
3604 assert_eq!(items.len(), 2);
3605 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
3606
3607 // The folder-bearing window is a single clickable action carrying the
3608 // stats label (the old label + Focus action, merged).
3609 let action = items
3610 .iter()
3611 .find_map(|i| match i {
3612 MenuItem::Action(a) => Some(a),
3613 _ => None,
3614 })
3615 .expect("a focus action");
3616 assert_eq!(action.id, "focus:k1");
3617 assert_eq!(action.label, "repo · a branch");
3618
3619 // The folderless window is a non-clickable label (not "solo · solo").
3620 let labels: Vec<&str> = items
3621 .iter()
3622 .filter_map(|i| match i {
3623 MenuItem::Label(t) => Some(t.as_str()),
3624 _ => None,
3625 })
3626 .collect();
3627 assert_eq!(labels, vec!["solo"]);
3628 }
3629
3630 #[tokio::test]
3631 async fn menu_and_status_shapes() {
3632 let svc = WorktreesService::new();
3633 // Empty.
3634 let menu = svc.menu();
3635 assert_eq!(menu.title, "Worktrees");
3636 assert!(matches!(
3637 menu.items.first(),
3638 Some(MenuItem::Label(text)) if text == "No open windows"
3639 ));
3640 let status = svc.status().await;
3641 assert_eq!(status.name, "worktrees");
3642 assert!(status.healthy);
3643 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
3644
3645 // Two folder-bearing windows in the same repo, plus one folderless
3646 // window that shares the repo but has nothing for `code` to open.
3647 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3648 .await
3649 .unwrap();
3650 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
3651 .await
3652 .unwrap();
3653 svc.handle(
3654 "register",
3655 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
3656 )
3657 .await
3658 .unwrap();
3659 let status = svc.status().await;
3660 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
3661
3662 let menu = svc.menu();
3663 // One line per window — no separator, no duplicate label.
3664 assert_eq!(menu.items.len(), 3);
3665 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
3666 let action_ids: Vec<&str> = menu
3667 .items
3668 .iter()
3669 .filter_map(|i| match i {
3670 MenuItem::Action(a) => Some(a.id.as_str()),
3671 _ => None,
3672 })
3673 .collect();
3674 // The two folder-bearing windows are clickable; the folderless one is a
3675 // plain Label, so it never yields a focus action.
3676 assert!(action_ids.contains(&"focus:w1"));
3677 assert!(action_ids.contains(&"focus:w2"));
3678 assert!(!action_ids.contains(&"focus:w3"));
3679 }
3680
3681 #[test]
3682 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
3683 // With no tokio runtime, the background task is never spawned, so the
3684 // bare service keeps computing `menu()` inline (what the tests rely on).
3685 let svc = WorktreesService::new();
3686 svc.start_menu_refresh();
3687 assert!(svc.refresh.lock().unwrap().is_none());
3688 }
3689
3690 #[tokio::test]
3691 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
3692 let svc = WorktreesService::new();
3693 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
3694 .await
3695 .unwrap();
3696 // Before the task runs, `menu()` computes inline from an empty cache.
3697 assert!(svc.menu_cache.lock().unwrap().is_none());
3698
3699 svc.start_menu_refresh();
3700 // Idempotent: a second call does not start a second task.
3701 svc.start_menu_refresh();
3702
3703 // The task fills the cache off the main thread; poll briefly for it.
3704 let mut filled = false;
3705 for _ in 0..100 {
3706 if svc.menu_cache.lock().unwrap().is_some() {
3707 filled = true;
3708 break;
3709 }
3710 tokio::time::sleep(Duration::from_millis(10)).await;
3711 }
3712 assert!(filled, "background refresh should populate the menu cache");
3713
3714 // `menu()` now serves the cache: one clickable line for the window.
3715 let menu = svc.menu();
3716 assert_eq!(menu.title, "Worktrees");
3717 assert!(menu
3718 .items
3719 .iter()
3720 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
3721
3722 // Shutdown cancels and joins the task, clearing the handle.
3723 svc.shutdown().await;
3724 assert!(svc.refresh.lock().unwrap().is_none());
3725 }
3726
3727 #[tokio::test]
3728 async fn default_constructs_an_empty_service() {
3729 let svc = WorktreesService::default();
3730 let payload = svc.handle("list", Value::Null).await.unwrap();
3731 assert_eq!(payload, json!({ "windows": [] }));
3732 }
3733
3734 // --- Push subscription (#1267) -----------------------------------------
3735
3736 #[tokio::test]
3737 async fn subscribe_streams_only_for_the_subscribe_op() {
3738 let svc = WorktreesService::new();
3739 // The one streaming op yields a stream; every other op (including the
3740 // request/reply worktrees ops) declines, so the server dispatches them
3741 // normally.
3742 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
3743 assert!(svc.subscribe("list", &Value::Null).is_none());
3744 assert!(svc.subscribe("register", &Value::Null).is_none());
3745 assert!(svc.subscribe("bogus", &Value::Null).is_none());
3746 }
3747
3748 #[tokio::test]
3749 async fn subscribe_snapshot_matches_the_tree_op() {
3750 let dir = tempfile::tempdir().unwrap();
3751 let repo = init_repo(dir.path());
3752 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
3753 repo.set_head("refs/heads/main").unwrap();
3754
3755 let svc = WorktreesService::new();
3756 let stream = svc
3757 .subscribe("subscribe", &Value::Null)
3758 .expect("subscribe stream");
3759 // No windows yet → no repos derived; the toggle rides along at its
3760 // default (show all).
3761 assert_eq!(
3762 stream.snapshot().await,
3763 json!({ "repos": [], "show_closed": true })
3764 );
3765
3766 // A window opens on the repo → the snapshot carries it, byte-identical to
3767 // what the `tree` op returns for the same registry state.
3768 svc.handle(
3769 "register",
3770 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
3771 )
3772 .await
3773 .unwrap();
3774 let snap = stream.snapshot().await;
3775 let tree = svc.handle("tree", Value::Null).await.unwrap();
3776 assert_eq!(snap, tree);
3777 let repos = snap["repos"].as_array().expect("repos array");
3778 assert_eq!(repos.len(), 1);
3779 assert_eq!(repos[0]["worktrees"][0]["branch"], json!("main"));
3780 }
3781
3782 #[tokio::test]
3783 async fn subscribe_changed_wakes_on_register() {
3784 let svc = WorktreesService::new();
3785 let mut stream = svc
3786 .subscribe("subscribe", &Value::Null)
3787 .expect("subscribe stream");
3788 // Idle: `changed()` must not resolve without a registry change.
3789 tokio::select! {
3790 () = stream.changed() => panic!("changed resolved with no registry change"),
3791 () = tokio::time::sleep(Duration::from_millis(50)) => {}
3792 }
3793 // A register bumps the change-notify → `changed()` resolves promptly.
3794 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
3795 .await
3796 .unwrap();
3797 tokio::time::timeout(Duration::from_secs(1), stream.changed())
3798 .await
3799 .expect("changed should resolve after a register");
3800 }
3801
3802 // --- Coalesced tree-snapshot cache (#1303) -----------------------------
3803
3804 #[tokio::test]
3805 async fn tree_cache_coalesces_reads_within_ttl_and_generation() {
3806 let reg = Arc::new(WorktreesRegistry::new());
3807 // A long TTL so only the generation gate is exercised here.
3808 let cache = TreeSnapshotCache::with_ttl(
3809 reg,
3810 Arc::new(PrStatusCache::new()),
3811 Duration::from_secs(60),
3812 );
3813 // The first read builds once.
3814 let first = cache.snapshot().await;
3815 assert_eq!(cache.compute_count(), 1);
3816 // Further reads with no registry change and within the TTL reuse the
3817 // cached value — no extra build, byte-identical result.
3818 let second = cache.snapshot().await;
3819 assert_eq!(
3820 cache.compute_count(),
3821 1,
3822 "an unchanged read must not rebuild"
3823 );
3824 assert_eq!(first, second);
3825 }
3826
3827 #[tokio::test]
3828 async fn tree_cache_single_flights_a_read_burst() {
3829 let reg = Arc::new(WorktreesRegistry::new());
3830 let cache = Arc::new(TreeSnapshotCache::with_ttl(
3831 reg,
3832 Arc::new(PrStatusCache::new()),
3833 Duration::from_secs(60),
3834 ));
3835 // A burst of concurrent readers — as N subscriber streams would wake
3836 // together on a change/tick — collapses to exactly one build; the rest
3837 // read the shared result (the acceptance criterion).
3838 let mut handles = Vec::new();
3839 for _ in 0..16 {
3840 let cache = cache.clone();
3841 handles.push(tokio::spawn(async move { cache.snapshot().await }));
3842 }
3843 let mut results = Vec::new();
3844 for handle in handles {
3845 results.push(handle.await.unwrap());
3846 }
3847 assert_eq!(
3848 cache.compute_count(),
3849 1,
3850 "a concurrent read burst must build the tree once"
3851 );
3852 assert!(
3853 results.windows(2).all(|w| w[0] == w[1]),
3854 "every reader must observe the identical snapshot"
3855 );
3856 }
3857
3858 #[tokio::test]
3859 async fn tree_cache_rebuilds_on_registry_change() {
3860 let reg = Arc::new(WorktreesRegistry::new());
3861 let cache = TreeSnapshotCache::with_ttl(
3862 reg.clone(),
3863 Arc::new(PrStatusCache::new()),
3864 Duration::from_secs(60),
3865 );
3866 cache.snapshot().await;
3867 assert_eq!(cache.compute_count(), 1);
3868 // A registry change bumps the generation, so the next read rebuilds even
3869 // though the (long) TTL has not expired — subscribers never see a stale
3870 // visible set.
3871 assert!(reg.set_show_closed(false));
3872 cache.snapshot().await;
3873 assert_eq!(
3874 cache.compute_count(),
3875 2,
3876 "a generation bump must force a rebuild"
3877 );
3878 }
3879
3880 #[tokio::test]
3881 async fn tree_cache_rebuilds_after_ttl_expiry() {
3882 let reg = Arc::new(WorktreesRegistry::new());
3883 // A zero TTL: every read is already past it, so a pure on-disk git change
3884 // still surfaces on the next tick with no registry bump needed.
3885 let cache =
3886 TreeSnapshotCache::with_ttl(reg, Arc::new(PrStatusCache::new()), Duration::ZERO);
3887 cache.snapshot().await;
3888 cache.snapshot().await;
3889 assert_eq!(
3890 cache.compute_count(),
3891 2,
3892 "an expired TTL must force a rebuild each read"
3893 );
3894 }
3895
3896 #[tokio::test]
3897 async fn subscribe_streams_share_one_build_per_generation() {
3898 let svc = WorktreesService::new();
3899 let s1 = svc
3900 .subscribe("subscribe", &Value::Null)
3901 .expect("subscribe stream");
3902 let s2 = svc
3903 .subscribe("subscribe", &Value::Null)
3904 .expect("subscribe stream");
3905 // Two windows' streams sampling the same registry state build the tree
3906 // once, not once per stream (#1303) — they share the service's cache.
3907 let a = s1.snapshot().await;
3908 let b = s2.snapshot().await;
3909 assert_eq!(a, b);
3910 assert_eq!(
3911 svc.tree_cache.compute_count(),
3912 1,
3913 "N streams on one generation must share a single build"
3914 );
3915 }
3916
3917 // --- Show/hide-closed toggle (#1301) -----------------------------------
3918
3919 #[tokio::test]
3920 async fn set_show_closed_toggles_the_snapshot_field() {
3921 let svc = WorktreesService::new();
3922 // The snapshot carries the toggle; it defaults to show-all.
3923 assert_eq!(
3924 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
3925 json!(true)
3926 );
3927 // Setting it flips the field the next snapshot reports.
3928 let reply = svc
3929 .handle("set-show-closed", json!({ "show_closed": false }))
3930 .await
3931 .unwrap();
3932 assert_eq!(reply, json!({ "ok": true }));
3933 assert_eq!(
3934 svc.handle("tree", Value::Null).await.unwrap()["show_closed"],
3935 json!(false)
3936 );
3937 }
3938
3939 #[tokio::test]
3940 async fn set_show_closed_rejects_a_non_boolean_payload() {
3941 let svc = WorktreesService::new();
3942 assert!(svc.handle("set-show-closed", json!({})).await.is_err());
3943 assert!(svc
3944 .handle("set-show-closed", json!({ "show_closed": "yes" }))
3945 .await
3946 .is_err());
3947 }
3948
3949 #[tokio::test]
3950 async fn set_show_closed_wakes_the_subscription() {
3951 let svc = WorktreesService::new();
3952 let mut stream = svc
3953 .subscribe("subscribe", &Value::Null)
3954 .expect("subscribe stream");
3955 // A real flip bumps the change-notify → `changed()` resolves promptly.
3956 svc.handle("set-show-closed", json!({ "show_closed": false }))
3957 .await
3958 .unwrap();
3959 tokio::time::timeout(Duration::from_secs(1), stream.changed())
3960 .await
3961 .expect("changed should resolve after a toggle flip");
3962 // The pushed snapshot now reflects the new toggle.
3963 assert_eq!(stream.snapshot().await["show_closed"], json!(false));
3964 }
3965
3966 #[tokio::test]
3967 async fn set_polling_toggles_the_snapshot_field_for_a_repo() {
3968 // #1376: enabling stamps `polling_enabled: true` on the repo; disabling
3969 // drops it (skip-if-false), so the extension colours the icon off the flag.
3970 let dir = tempfile::tempdir().unwrap();
3971 github_repo(dir.path());
3972 let svc = WorktreesService::new();
3973 svc.handle(
3974 "register",
3975 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
3976 )
3977 .await
3978 .unwrap();
3979
3980 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
3981 assert!(
3982 repo.get("polling_enabled").is_none(),
3983 "default off omits the flag: {repo:?}"
3984 );
3985
3986 let reply = svc
3987 .handle(
3988 "set-polling",
3989 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
3990 )
3991 .await
3992 .unwrap();
3993 assert_eq!(reply, json!({ "ok": true }));
3994 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
3995 assert_eq!(repo["polling_enabled"], json!(true));
3996
3997 svc.handle(
3998 "set-polling",
3999 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
4000 )
4001 .await
4002 .unwrap();
4003 let repo = repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0].clone();
4004 assert!(repo.get("polling_enabled").is_none());
4005 }
4006
4007 #[tokio::test]
4008 async fn set_polling_rejects_missing_or_empty_fields() {
4009 let svc = WorktreesService::new();
4010 // Missing `enabled`.
4011 assert!(svc
4012 .handle("set-polling", json!({ "owner": "o", "name": "n" }))
4013 .await
4014 .is_err());
4015 // Missing `owner`/`name`.
4016 assert!(svc
4017 .handle("set-polling", json!({ "enabled": true }))
4018 .await
4019 .is_err());
4020 // Blank `owner`/`name`.
4021 assert!(svc
4022 .handle(
4023 "set-polling",
4024 json!({ "owner": " ", "name": "n", "enabled": true })
4025 )
4026 .await
4027 .is_err());
4028 }
4029
4030 #[tokio::test]
4031 async fn set_polling_wakes_the_subscription() {
4032 let dir = tempfile::tempdir().unwrap();
4033 github_repo(dir.path());
4034 let svc = WorktreesService::new();
4035 svc.handle(
4036 "register",
4037 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
4038 )
4039 .await
4040 .unwrap();
4041 let mut stream = svc
4042 .subscribe("subscribe", &Value::Null)
4043 .expect("subscribe stream");
4044 svc.handle(
4045 "set-polling",
4046 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
4047 )
4048 .await
4049 .unwrap();
4050 tokio::time::timeout(Duration::from_secs(1), stream.changed())
4051 .await
4052 .expect("changed should resolve after enabling a repo");
4053 let repo = repos_of(&stream.snapshot().await)[0].clone();
4054 assert_eq!(repo["polling_enabled"], json!(true));
4055 }
4056
4057 #[tokio::test]
4058 async fn disabling_a_repo_drops_its_pr_badges_immediately() {
4059 // The "drop existing badges immediately" requirement (#1376), done
4060 // daemon-side: the fold skips a not-polled repo, so a disable strips the
4061 // badge on the very next snapshot rather than waiting for a poll.
4062 let dir = tempfile::tempdir().unwrap();
4063 let repo = github_repo(dir.path());
4064 let head = repo.head().unwrap().target().unwrap().to_string();
4065 let svc = WorktreesService::new();
4066 svc.handle(
4067 "register",
4068 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
4069 )
4070 .await
4071 .unwrap();
4072 svc.registry.set_polling("rust-works", "omni-dev", true);
4073
4074 let mut badges = HashMap::new();
4075 badges.insert(
4076 PrTarget {
4077 owner: "rust-works".into(),
4078 name: "omni-dev".into(),
4079 branch: "main".into(),
4080 },
4081 pr(pending_badge(7, &head)),
4082 );
4083 svc.pr_cache.replace(badges);
4084
4085 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4086 assert_eq!(wt["pr"]["number"], json!(7));
4087
4088 svc.handle(
4089 "set-polling",
4090 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
4091 )
4092 .await
4093 .unwrap();
4094 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4095 assert!(
4096 wt.get("pr").is_none(),
4097 "a disabled repo must carry no badge: {wt:?}"
4098 );
4099 }
4100
4101 #[tokio::test]
4102 async fn an_expired_lease_drops_the_flag_and_badges() {
4103 // The 15-minute auto-expire (#1376) seen end-to-end: once a repo's lease
4104 // elapses, the snapshot drops `polling_enabled` *and* the badge, and the
4105 // poller would no longer watch it — all reaped on read, no timer.
4106 let dir = tempfile::tempdir().unwrap();
4107 let repo = github_repo(dir.path());
4108 let head = repo.head().unwrap().target().unwrap().to_string();
4109 let svc = WorktreesService::new();
4110 svc.handle(
4111 "register",
4112 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
4113 )
4114 .await
4115 .unwrap();
4116 svc.registry.set_polling("rust-works", "omni-dev", true);
4117 let mut badges = HashMap::new();
4118 badges.insert(
4119 PrTarget {
4120 owner: "rust-works".into(),
4121 name: "omni-dev".into(),
4122 branch: "main".into(),
4123 },
4124 pr(pending_badge(7, &head)),
4125 );
4126 svc.pr_cache.replace(badges);
4127
4128 // Leased: flag stamped, badge folded, and the poller would watch it.
4129 let snap = svc.handle("tree", Value::Null).await.unwrap();
4130 assert_eq!(repos_of(&snap)[0]["polling_enabled"], json!(true));
4131 assert_eq!(repos_of(&snap)[0]["worktrees"][0]["pr"]["number"], json!(7));
4132 assert_eq!(pr_targets_from_snapshot(&snap).len(), 1);
4133
4134 // Force the lease into the past — as 15 minutes elapsing would.
4135 svc.registry.set_polling_expiry(
4136 "rust-works",
4137 "omni-dev",
4138 Utc::now() - chrono::Duration::minutes(1),
4139 );
4140
4141 let snap = svc.handle("tree", Value::Null).await.unwrap();
4142 let repo0 = &repos_of(&snap)[0];
4143 assert!(
4144 repo0.get("polling_enabled").is_none(),
4145 "expired lease drops the flag: {repo0:?}"
4146 );
4147 assert!(
4148 repo0["worktrees"][0].get("pr").is_none(),
4149 "expired lease drops the badge"
4150 );
4151 assert!(
4152 pr_targets_from_snapshot(&snap).is_empty(),
4153 "the poller no longer watches an expired repo"
4154 );
4155 }
4156
4157 #[tokio::test]
4158 async fn polling_prefs_persist_across_reloads_with_0600() {
4159 // The enable set survives a daemon restart (#1376): a change writes the
4160 // `0600` file, and a fresh service seeded from it comes up enabled.
4161 let dir = tempfile::tempdir().unwrap();
4162 let prefs = dir.path().join("worktrees-polling.json");
4163
4164 let svc = WorktreesService::new();
4165 svc.load_polling_prefs(prefs.clone());
4166 assert!(!svc.registry.is_polling_enabled("rust-works", "omni-dev"));
4167 svc.handle(
4168 "set-polling",
4169 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": true }),
4170 )
4171 .await
4172 .unwrap();
4173 assert!(prefs.exists());
4174 #[cfg(unix)]
4175 {
4176 use std::os::unix::fs::PermissionsExt;
4177 assert_eq!(
4178 std::fs::metadata(&prefs).unwrap().permissions().mode() & 0o777,
4179 0o600
4180 );
4181 }
4182
4183 // A new service reloads the enabled set from that file.
4184 let svc2 = WorktreesService::new();
4185 svc2.load_polling_prefs(prefs.clone());
4186 assert!(svc2.registry.is_polling_enabled("rust-works", "omni-dev"));
4187
4188 // Disabling rewrites the file, so the next reload has nothing enabled.
4189 svc2.handle(
4190 "set-polling",
4191 json!({ "owner": "rust-works", "name": "omni-dev", "enabled": false }),
4192 )
4193 .await
4194 .unwrap();
4195 let svc3 = WorktreesService::new();
4196 svc3.load_polling_prefs(prefs);
4197 assert!(!svc3.registry.is_polling_enabled("rust-works", "omni-dev"));
4198 }
4199
4200 #[test]
4201 fn load_polling_prefs_tolerates_a_corrupt_or_unreadable_file() {
4202 // Best-effort load (#1376): a hand-edited/corrupt file or an unreadable
4203 // path is logged and treated as "nothing enabled" rather than wedging the
4204 // service. A missing file is already the first-run default (covered by the
4205 // persistence round-trip above); this exercises the two error branches.
4206 let dir = tempfile::tempdir().unwrap();
4207
4208 // Corrupt JSON — the parse error is swallowed, nothing is enabled.
4209 let corrupt = dir.path().join("worktrees-polling.json");
4210 std::fs::write(&corrupt, b"{ not valid json ]").unwrap();
4211 let svc = WorktreesService::new();
4212 svc.load_polling_prefs(corrupt);
4213 assert!(svc.registry.enabled_polling_repos().is_empty());
4214
4215 // A directory at the path — the (non-NotFound) read error is swallowed too.
4216 let as_dir = dir.path().join("is-a-directory");
4217 std::fs::create_dir(&as_dir).unwrap();
4218 let svc2 = WorktreesService::new();
4219 svc2.load_polling_prefs(as_dir);
4220 assert!(svc2.registry.enabled_polling_repos().is_empty());
4221 }
4222
4223 #[tokio::test]
4224 async fn pr_poller_asks_nothing_for_a_registered_but_not_enabled_repo() {
4225 // The zero-`gh` guarantee (#1376): a window is open on a GitHub repo, but
4226 // the user has not enabled polling for it — so the poller spawns no `gh`.
4227 let dir = tempfile::tempdir().unwrap();
4228 github_repo(dir.path());
4229 let bin_dir = tempfile::tempdir().unwrap();
4230 let marker = bin_dir.path().join("spawned");
4231 let fake = bin_dir.path().join("fake-gh");
4232 std::fs::write(
4233 &fake,
4234 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
4235 )
4236 .unwrap();
4237 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
4238 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
4239 std::fs::set_permissions(&fake, perms).unwrap();
4240
4241 let svc = WorktreesService::new();
4242 svc.handle(
4243 "register",
4244 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
4245 )
4246 .await
4247 .unwrap();
4248 // Deliberately NOT enabling polling for the repo.
4249 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
4250 tokio::time::sleep(Duration::from_millis(200)).await;
4251 svc.shutdown().await;
4252 assert!(
4253 !marker.exists(),
4254 "a registered-but-not-enabled repo must drive zero gh"
4255 );
4256 }
4257
4258 #[tokio::test]
4259 async fn menu_action_rejects_unknown_and_missing_window() {
4260 let svc = WorktreesService::new();
4261 assert!(svc.menu_action("bogus").await.is_err());
4262 // A focus for a key with no registration errors rather than spawning.
4263 assert!(svc.menu_action("focus:nope").await.is_err());
4264 svc.shutdown().await;
4265 }
4266
4267 /// Restores `OMNI_DEV_VSCODE_BIN` on drop. The two spawn tests that read the
4268 /// variable (via `resolve_code_binary` → `focus_window`) —
4269 /// `menu_action_focus_resolves_folder_and_spawns` and
4270 /// `open_focuses_an_existing_absolute_dir` — both point the launcher at the
4271 /// same harmless `/bin/sh`, and no test asserts the variable is *unset*, so a
4272 /// transient overlap under the harness's test parallelism is benign.
4273 struct VscodeBinGuard(Option<std::ffi::OsString>);
4274 impl Drop for VscodeBinGuard {
4275 fn drop(&mut self) {
4276 match self.0.take() {
4277 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
4278 None => std::env::remove_var(VSCODE_BIN_ENV),
4279 }
4280 }
4281 }
4282
4283 #[tokio::test]
4284 async fn menu_action_focus_resolves_folder_and_spawns() {
4285 let dir = tempfile::tempdir().unwrap();
4286 let svc = WorktreesService::new();
4287 svc.handle(
4288 "register",
4289 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
4290 )
4291 .await
4292 .unwrap();
4293
4294 // Point the launcher at a harmless binary so the spawn deterministically
4295 // succeeds and the focus path returns Ok.
4296 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
4297 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
4298 svc.menu_action("focus:w1").await.unwrap();
4299 }
4300
4301 #[tokio::test]
4302 async fn open_rejects_missing_relative_or_nonexistent_path() {
4303 let svc = WorktreesService::new();
4304 // A missing `path` is a payload error.
4305 assert!(svc.handle("open", json!({})).await.is_err());
4306 assert!(svc.handle("open", json!({ "path": 42 })).await.is_err());
4307 // A relative path is rejected before any spawn — this is also what
4308 // blocks a `-`-leading argument from reaching `code` as a flag.
4309 assert!(svc
4310 .handle("open", json!({ "path": "relative/dir" }))
4311 .await
4312 .is_err());
4313 assert!(svc
4314 .handle("open", json!({ "path": "-flag" }))
4315 .await
4316 .is_err());
4317 // An absolute path that does not exist is rejected before any spawn, so
4318 // no launcher is needed for these guard cases.
4319 assert!(svc
4320 .handle("open", json!({ "path": "/no/such/abs/dir/xyzzy" }))
4321 .await
4322 .is_err());
4323 svc.shutdown().await;
4324 }
4325
4326 #[tokio::test]
4327 async fn open_focuses_an_existing_absolute_dir() {
4328 let dir = tempfile::tempdir().unwrap();
4329 let svc = WorktreesService::new();
4330 // Pin the launcher to a harmless binary so the spawn deterministically
4331 // succeeds whether or not `code` is installed. Unlike the tray `focus`
4332 // path, `open` takes the folder straight from the payload — no prior
4333 // `register` is required.
4334 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
4335 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
4336 let reply = svc
4337 .handle("open", json!({ "path": dir.path() }))
4338 .await
4339 .unwrap();
4340 assert_eq!(reply, json!({ "ok": true }));
4341 svc.shutdown().await;
4342 }
4343
4344 #[test]
4345 fn focus_window_with_validates_folder_then_spawns() {
4346 let dir = tempfile::tempdir().unwrap();
4347 // Non-absolute and missing-directory folders are rejected before spawn.
4348 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
4349 assert!(
4350 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
4351 );
4352 // A valid absolute directory spawns the launcher successfully.
4353 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
4354 // A missing launcher surfaces the spawn error (with context), not Ok.
4355 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
4356 }
4357
4358 #[test]
4359 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
4360 // Env override wins outright.
4361 assert_eq!(
4362 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
4363 PathBuf::from("/custom/code")
4364 );
4365 // No override: the first existing candidate is chosen.
4366 let existing = tempfile::NamedTempFile::new().unwrap();
4367 let existing_path = existing.path().to_str().unwrap();
4368 assert_eq!(
4369 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
4370 PathBuf::from(existing_path)
4371 );
4372 // Nothing exists: fall back to bare `code` on PATH.
4373 assert_eq!(
4374 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
4375 PathBuf::from("code")
4376 );
4377 // The real-env wrapper resolves without panicking.
4378 let _ = resolve_code_binary();
4379 }
4380
4381 // --- Git enrichment (#1186) --------------------------------------------
4382
4383 /// Initializes a fresh repo with a deterministic identity so `commit()`
4384 /// works without depending on a global git config.
4385 fn init_repo(dir: &Path) -> Repository {
4386 let repo = Repository::init(dir).unwrap();
4387 let mut cfg = repo.config().unwrap();
4388 cfg.set_str("user.name", "Test").unwrap();
4389 cfg.set_str("user.email", "test@example.com").unwrap();
4390 repo
4391 }
4392
4393 /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
4394 /// optionally moving `refname` to it, and returns its oid.
4395 fn empty_commit(
4396 repo: &Repository,
4397 refname: Option<&str>,
4398 parents: &[&git2::Commit<'_>],
4399 msg: &str,
4400 ) -> git2::Oid {
4401 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
4402 let tree = repo
4403 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
4404 .unwrap();
4405 repo.commit(refname, &sig, &sig, msg, &tree, parents)
4406 .unwrap()
4407 }
4408
4409 /// Commits `content` as file `name` onto `refname`, chaining off its current
4410 /// tip (if any). Unlike [`empty_commit`], the tree carries a real blob, so
4411 /// the file is checked out into a worktree and can then be modified to
4412 /// produce a dirty (tracked) status.
4413 fn commit_file(
4414 repo: &Repository,
4415 refname: &str,
4416 name: &str,
4417 content: &[u8],
4418 msg: &str,
4419 ) -> git2::Oid {
4420 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
4421 let blob = repo.blob(content).unwrap();
4422 let mut builder = repo.treebuilder(None).unwrap();
4423 builder.insert(name, blob, 0o100_644).unwrap();
4424 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
4425 let parent = repo
4426 .refname_to_id(refname)
4427 .ok()
4428 .and_then(|oid| repo.find_commit(oid).ok());
4429 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
4430 repo.commit(Some(refname), &sig, &sig, msg, &tree, &parents)
4431 .unwrap()
4432 }
4433
4434 /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
4435 /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
4436 fn diverging_repo(dir: &Path) -> Repository {
4437 let repo = init_repo(dir);
4438 // A: the shared base on `main`.
4439 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4440 let a_commit = repo.find_commit(a).unwrap();
4441 // origin/main diverges to C, a sibling of the local tip.
4442 let c = empty_commit(&repo, None, &[&a_commit], "C");
4443 repo.reference("refs/remotes/origin/main", c, true, "origin main")
4444 .unwrap();
4445 // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
4446 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
4447 // Release the commit's borrow of `repo` so it can be returned.
4448 drop(a_commit);
4449 repo.set_head("refs/heads/main").unwrap();
4450 // Configure the tracking relationship so `upstream()` resolves.
4451 let mut cfg = repo.config().unwrap();
4452 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
4453 .unwrap();
4454 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
4455 .unwrap();
4456 cfg.set_str("branch.main.remote", "origin").unwrap();
4457 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
4458 repo
4459 }
4460
4461 #[test]
4462 fn git_status_reads_branch_and_ahead_behind() {
4463 let dir = tempfile::tempdir().unwrap();
4464 let _repo = diverging_repo(dir.path());
4465 let status = git_status(dir.path());
4466 assert_eq!(status.branch.as_deref(), Some("main"));
4467 assert_eq!(status.ahead, Some(1));
4468 assert_eq!(status.behind, Some(1));
4469 // A normal checkout names itself and is not flagged a worktree.
4470 assert_eq!(
4471 status.main_repo.as_deref(),
4472 dir.path().file_name().and_then(|n| n.to_str())
4473 );
4474 assert!(!status.is_worktree);
4475 }
4476
4477 #[test]
4478 fn git_status_empty_repo_is_unborn() {
4479 // A repo with no commits has an unborn HEAD, so `head()` errors and the
4480 // branch/sync fields stay empty rather than panicking — but the repo
4481 // identity is still resolved from the common dir.
4482 let dir = tempfile::tempdir().unwrap();
4483 init_repo(dir.path());
4484 let status = git_status(dir.path());
4485 assert_eq!(status.branch, None);
4486 // An unborn HEAD has no commit to name, so the SHA is absent too (#1337).
4487 assert_eq!(status.head_sha, None);
4488 assert_eq!(status.ahead, None);
4489 assert_eq!(status.behind, None);
4490 assert_eq!(
4491 status.main_repo.as_deref(),
4492 dir.path().file_name().and_then(|n| n.to_str())
4493 );
4494 assert!(!status.is_worktree);
4495 }
4496
4497 #[test]
4498 fn git_status_no_upstream_reports_branch_only() {
4499 let dir = tempfile::tempdir().unwrap();
4500 let repo = init_repo(dir.path());
4501 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4502 repo.set_head("refs/heads/main").unwrap();
4503 let status = git_status(dir.path());
4504 assert_eq!(status.branch.as_deref(), Some("main"));
4505 // No upstream → ahead/behind stay absent rather than zero.
4506 assert_eq!(status.ahead, None);
4507 assert_eq!(status.behind, None);
4508 // …and so does the upstream SHA, so such a branch still renders with no
4509 // sync indicator at all (#1344).
4510 assert_eq!(status.upstream_sha, None);
4511 }
4512
4513 #[test]
4514 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
4515 // A plain directory that is not a git repo yields nothing at all.
4516 let plain = tempfile::tempdir().unwrap();
4517 assert_eq!(git_status(plain.path()), GitStatus::default());
4518
4519 // A detached HEAD reports no branch (and thus no sync), but the repo
4520 // identity is still resolved from the common dir.
4521 let dir = tempfile::tempdir().unwrap();
4522 let repo = init_repo(dir.path());
4523 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4524 repo.set_head_detached(a).unwrap();
4525 let status = git_status(dir.path());
4526 assert_eq!(status.branch, None);
4527 // A detached HEAD has no branch but *does* have a commit — the SHA is
4528 // resolved before the branch filter, so it survives here (#1337).
4529 assert_eq!(status.head_sha.as_deref(), Some(a.to_string().as_str()));
4530 assert_eq!(status.ahead, None);
4531 assert_eq!(status.behind, None);
4532 // A detached HEAD has no branch, so there is no upstream to resolve
4533 // either — the branch filter returns before the wrap (#1344).
4534 assert_eq!(status.upstream_sha, None);
4535 assert_eq!(
4536 status.main_repo.as_deref(),
4537 dir.path().file_name().and_then(|n| n.to_str())
4538 );
4539 assert!(!status.is_worktree);
4540 }
4541
4542 // --- Lazy ahead/behind (#1306) -----------------------------------------
4543
4544 #[test]
4545 fn git_status_cheap_reads_branch_but_skips_the_divergence_walk() {
4546 // The same repo `git_status` reports 1/1 for. The cheap variant used by
4547 // the streamed tree snapshot still reads the branch and repo identity, but
4548 // leaves ahead/behind absent — divergence is now lazy (#1306).
4549 let dir = tempfile::tempdir().unwrap();
4550 let repo = diverging_repo(dir.path());
4551 let status = git_status_cheap(dir.path());
4552 assert_eq!(status.branch.as_deref(), Some("main"));
4553 assert_eq!(status.ahead, None);
4554 assert_eq!(status.behind, None);
4555 assert_eq!(
4556 status.main_repo.as_deref(),
4557 dir.path().file_name().and_then(|n| n.to_str())
4558 );
4559 // The SHA rides the *cheap* path deliberately: it is a refs read, not a
4560 // revwalk, and it is what makes a new commit a snapshot delta (#1337).
4561 let head = repo.head().unwrap().target().unwrap();
4562 assert_eq!(status.head_sha.as_deref(), Some(head.to_string().as_str()));
4563 }
4564
4565 // --- HEAD SHA on the snapshot (#1337) ----------------------------------
4566
4567 #[test]
4568 fn git_status_head_sha_tracks_new_commits() {
4569 // The regression #1337 turns on: a commit must change the status the
4570 // snapshot is built from. Before the SHA rode the payload, committing
4571 // changed nothing on the wire, the server's diff dropped the identical
4572 // snapshot, and no client re-rendered — so a badge computed for the old
4573 // head survived the push that invalidated it.
4574 let dir = tempfile::tempdir().unwrap();
4575 let repo = init_repo(dir.path());
4576 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4577 repo.set_head("refs/heads/main").unwrap();
4578 let before = git_status_cheap(dir.path());
4579 assert_eq!(before.head_sha.as_deref(), Some(a.to_string().as_str()));
4580
4581 let head = repo.find_commit(a).unwrap();
4582 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
4583 let after = git_status_cheap(dir.path());
4584 assert_eq!(after.head_sha.as_deref(), Some(b.to_string().as_str()));
4585 assert_ne!(before.head_sha, after.head_sha);
4586 // The branch is unchanged — the SHA is the *only* thing that moved, which
4587 // is exactly why its absence made the push invisible.
4588 assert_eq!(before.branch, after.branch);
4589 }
4590
4591 // --- Upstream SHA on the snapshot (#1344) ------------------------------
4592
4593 /// Repoints `refs/remotes/origin/main` at `oid` — exactly what a `git push`
4594 /// does, and all of what it does: the local branch and HEAD do not move. Lets
4595 /// these tests exercise a push with no network and no second repo.
4596 fn simulate_push(repo: &Repository, oid: git2::Oid) {
4597 repo.reference("refs/remotes/origin/main", oid, true, "push")
4598 .unwrap();
4599 }
4600
4601 #[test]
4602 fn git_status_upstream_sha_tracks_a_push() {
4603 // The regression #1344 turns on. `diverging_repo` leaves local `main` at B
4604 // and origin/main at C — 1 ahead, 1 behind. Pushing B moves *only* the
4605 // remote-tracking ref, so before this field rode the payload every wire
4606 // field was byte-identical across the push, the server's diff dropped the
4607 // snapshot, no client re-rendered, and the lazily-fetched ahead/behind was
4608 // never re-asked — the row showed `↑1 ↓0` forever.
4609 let dir = tempfile::tempdir().unwrap();
4610 let repo = diverging_repo(dir.path());
4611 let before = git_status(dir.path());
4612 assert_eq!(before.ahead, Some(1));
4613 assert_eq!(before.behind, Some(1));
4614
4615 let head = repo.head().unwrap().target().unwrap();
4616 simulate_push(&repo, head);
4617 let after = git_status(dir.path());
4618
4619 // The upstream now names the pushed commit, and the counts agree.
4620 assert_eq!(
4621 after.upstream_sha.as_deref(),
4622 Some(head.to_string().as_str())
4623 );
4624 assert_ne!(before.upstream_sha, after.upstream_sha);
4625 assert_eq!(after.ahead, Some(0));
4626 assert_eq!(after.behind, Some(0));
4627 // Nothing else moved — which is the whole point. A push leaves the branch
4628 // and the local head exactly where they were, so `upstream_sha` is the
4629 // only signal a client could possibly notice.
4630 assert_eq!(before.branch, after.branch);
4631 assert_eq!(before.head_sha, after.head_sha);
4632 }
4633
4634 #[test]
4635 fn git_status_cheap_reports_upstream_sha() {
4636 // The crux: the field has to ride the *cheap* path, since that is the one
4637 // the streamed snapshot is built from. Costing a config lookup and a refs
4638 // read — no revwalk — it clears the bar #1306 set, unlike the divergence
4639 // walk still absent here.
4640 let dir = tempfile::tempdir().unwrap();
4641 let repo = diverging_repo(dir.path());
4642 let status = git_status_cheap(dir.path());
4643 let upstream = repo
4644 .find_branch("origin/main", git2::BranchType::Remote)
4645 .unwrap()
4646 .get()
4647 .target()
4648 .unwrap();
4649 assert_eq!(
4650 status.upstream_sha.as_deref(),
4651 Some(upstream.to_string().as_str())
4652 );
4653 assert_eq!(status.ahead, None);
4654 assert_eq!(status.behind, None);
4655 }
4656
4657 #[test]
4658 fn folder_ahead_behind_computes_divergence_and_degrades() {
4659 // A diverging tracking branch → the on-demand walk reports (ahead, behind).
4660 let dir = tempfile::tempdir().unwrap();
4661 let _repo = diverging_repo(dir.path());
4662 assert_eq!(folder_ahead_behind(dir.path()), Some((1, 1)));
4663
4664 // A branch with no upstream → None (the tree renders no sync indicator).
4665 let no_up = tempfile::tempdir().unwrap();
4666 let repo = init_repo(no_up.path());
4667 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4668 repo.set_head("refs/heads/main").unwrap();
4669 assert_eq!(folder_ahead_behind(no_up.path()), None);
4670
4671 // A detached HEAD and a plain (non-repo) directory → None.
4672 let detached = tempfile::tempdir().unwrap();
4673 let drepo = init_repo(detached.path());
4674 let a = empty_commit(&drepo, Some("refs/heads/main"), &[], "A");
4675 drepo.set_head_detached(a).unwrap();
4676 assert_eq!(folder_ahead_behind(detached.path()), None);
4677 let plain = tempfile::tempdir().unwrap();
4678 assert_eq!(folder_ahead_behind(plain.path()), None);
4679 }
4680
4681 #[tokio::test]
4682 async fn ahead_behind_op_returns_divergence_keyed_by_path_and_omits_no_upstream() {
4683 let diverging = tempfile::tempdir().unwrap();
4684 let _d = diverging_repo(diverging.path());
4685 let no_up = tempfile::tempdir().unwrap();
4686 let repo = init_repo(no_up.path());
4687 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4688 repo.set_head("refs/heads/main").unwrap();
4689
4690 let svc = WorktreesService::new();
4691 let diverging_path = diverging.path().display().to_string();
4692 let no_up_path = no_up.path().display().to_string();
4693 let reply = svc
4694 .handle(
4695 "ahead-behind",
4696 json!({ "paths": [&diverging_path, &no_up_path] }),
4697 )
4698 .await
4699 .unwrap();
4700 let results = reply.get("results").unwrap();
4701 // The diverging worktree carries its counts, keyed by the requested path.
4702 let d = results.get(diverging_path.as_str()).unwrap();
4703 assert_eq!(d.get("ahead").and_then(Value::as_u64), Some(1));
4704 assert_eq!(d.get("behind").and_then(Value::as_u64), Some(1));
4705 // The no-upstream worktree is omitted entirely, not reported as zero.
4706 assert!(results.get(no_up_path.as_str()).is_none(), "{results:?}");
4707
4708 // A missing/empty `paths` list yields an empty results object, not an error.
4709 let empty = svc.handle("ahead-behind", json!({})).await.unwrap();
4710 assert_eq!(empty.get("results"), Some(&json!({})));
4711 }
4712
4713 #[tokio::test]
4714 async fn tree_snapshot_omits_ahead_behind_for_a_diverging_worktree() {
4715 // A window on a repo whose branch is 1 ahead of / 1 behind its upstream.
4716 let dir = tempfile::tempdir().unwrap();
4717 let _repo = diverging_repo(dir.path());
4718 let svc = WorktreesService::new();
4719 svc.handle(
4720 "register",
4721 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4722 )
4723 .await
4724 .unwrap();
4725
4726 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
4727 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
4728 let main_wt = &worktrees[0];
4729 // The cheap parts are present, but divergence is not — it is fetched
4730 // lazily via the `ahead-behind` op (#1306).
4731 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
4732 assert!(main_wt.get("ahead").is_none(), "{main_wt:?}");
4733 assert!(main_wt.get("behind").is_none(), "{main_wt:?}");
4734 }
4735
4736 #[tokio::test]
4737 async fn tree_snapshot_carries_head_sha_so_a_commit_is_a_real_delta() {
4738 // The end-to-end shape of the #1337 freshness fix. The server pushes a
4739 // snapshot only when it differs from the last one
4740 // (`server.rs`: `if snap != last`), so anything invisible on the wire
4741 // cannot trigger a re-render. Committing must therefore move the payload.
4742 let dir = tempfile::tempdir().unwrap();
4743 let repo = init_repo(dir.path());
4744 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4745 repo.set_head("refs/heads/main").unwrap();
4746
4747 let svc = WorktreesService::new();
4748 svc.handle(
4749 "register",
4750 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4751 )
4752 .await
4753 .unwrap();
4754
4755 let before = svc.handle("tree", Value::Null).await.unwrap();
4756 let wt = &repos_of(&before)[0]["worktrees"][0];
4757 assert_eq!(
4758 wt.get("head_sha").and_then(Value::as_str),
4759 Some(a.to_string().as_str())
4760 );
4761
4762 // Commit again: same branch, same paths, same open windows — pre-#1337 the
4763 // snapshot was byte-identical here and the push was dropped.
4764 let head = repo.find_commit(a).unwrap();
4765 let b = empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
4766 let after = svc.handle("tree", Value::Null).await.unwrap();
4767 assert_eq!(
4768 repos_of(&after)[0]["worktrees"][0]
4769 .get("head_sha")
4770 .and_then(Value::as_str),
4771 Some(b.to_string().as_str())
4772 );
4773 assert_ne!(before, after, "a commit must be a visible snapshot delta");
4774 }
4775
4776 #[tokio::test]
4777 async fn tree_snapshot_omits_head_sha_for_an_unborn_repo() {
4778 // Wire-compat: an absent SHA is dropped entirely rather than sent as null,
4779 // matching the payload's `skip_serializing_if` convention.
4780 let dir = tempfile::tempdir().unwrap();
4781 init_repo(dir.path());
4782 let svc = WorktreesService::new();
4783 svc.handle(
4784 "register",
4785 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4786 )
4787 .await
4788 .unwrap();
4789 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4790 assert!(wt.get("head_sha").is_none(), "{wt:?}");
4791 }
4792
4793 // --- Upstream SHA on the snapshot (#1344) ------------------------------
4794
4795 #[tokio::test]
4796 async fn tree_snapshot_carries_upstream_sha_so_a_push_is_a_real_delta() {
4797 // The end-to-end shape of the #1344 fix, one ref over from #1337. A push
4798 // moves neither the branch nor the local head, so `upstream_sha` is the
4799 // only field that can carry the news. Without it the snapshot serialised
4800 // byte-identically, `server.rs`'s `if snap != last` dropped the frame, no
4801 // window re-rendered, and the lazy ahead/behind was never re-fetched.
4802 let dir = tempfile::tempdir().unwrap();
4803 let repo = diverging_repo(dir.path());
4804 let svc = WorktreesService::new();
4805 svc.handle(
4806 "register",
4807 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4808 )
4809 .await
4810 .unwrap();
4811
4812 let before = svc.handle("tree", Value::Null).await.unwrap();
4813 let head = repo.head().unwrap().target().unwrap();
4814 assert_ne!(
4815 repos_of(&before)[0]["worktrees"][0]
4816 .get("upstream_sha")
4817 .and_then(Value::as_str),
4818 Some(head.to_string().as_str()),
4819 "the fixture must start un-pushed for this to prove anything"
4820 );
4821
4822 // Push: only `refs/remotes/origin/main` moves.
4823 simulate_push(&repo, head);
4824 let after = svc.handle("tree", Value::Null).await.unwrap();
4825 let wt = &repos_of(&after)[0]["worktrees"][0];
4826 assert_eq!(
4827 wt.get("upstream_sha").and_then(Value::as_str),
4828 Some(head.to_string().as_str())
4829 );
4830 // The head and branch are untouched across the push — so this delta rests
4831 // entirely on `upstream_sha`.
4832 assert_eq!(
4833 wt.get("head_sha").and_then(Value::as_str),
4834 repos_of(&before)[0]["worktrees"][0]
4835 .get("head_sha")
4836 .and_then(Value::as_str)
4837 );
4838 assert_ne!(before, after, "a push must be a visible snapshot delta");
4839 }
4840
4841 #[tokio::test]
4842 async fn tree_snapshot_omits_upstream_sha_without_an_upstream() {
4843 // Wire-compat, and the no-regression case: a branch tracking nothing sends
4844 // no key at all rather than a null, so an older client sees exactly the
4845 // payload it saw before and still renders no sync indicator.
4846 let dir = tempfile::tempdir().unwrap();
4847 let repo = init_repo(dir.path());
4848 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4849 repo.set_head("refs/heads/main").unwrap();
4850 let svc = WorktreesService::new();
4851 svc.handle(
4852 "register",
4853 json!({ "key": "w", "folders": [dir.path()], "repo": "x" }),
4854 )
4855 .await
4856 .unwrap();
4857 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
4858 assert!(wt.get("upstream_sha").is_none(), "{wt:?}");
4859 // The head still rides, so this is specifically the upstream degrading.
4860 assert!(wt.get("head_sha").is_some(), "{wt:?}");
4861 }
4862
4863 // --- PR badge poller (#1337) -------------------------------------------
4864
4865 /// Writes an executable stub that ignores its arguments and prints `stdout`,
4866 /// standing in for `gh api graphql` so the poll loop is exercised offline.
4867 /// Returns the shim lock alongside the path: the caller **must** hold the
4868 /// guard until the poller has finished exec'ing the stub. Writing an
4869 /// executable and then `execve`ing it races every other thread that forks —
4870 /// the child inherits the still-open writable FD and the exec fails
4871 /// `ETXTBSY`. See [`crate::pr_status`]'s twin helper (#642, #1344).
4872 fn fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>) {
4873 let guard = shim_lock();
4874 let path = dir.join("fake-gh");
4875 write_exec_script(&path, &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\n"));
4876 (path, guard)
4877 }
4878
4879 /// A [`fake_gh`] that also records **ground truth**: each invocation appends a
4880 /// byte to a counter file before printing `stdout`. The returned counter path
4881 /// lets a test assert how many `gh` subprocesses actually ran, independent of
4882 /// the #1387 request-log counter — so the two can be compared (#1389).
4883 fn counting_fake_gh(dir: &Path, stdout: &str) -> (PathBuf, MutexGuard<'static, ()>, PathBuf) {
4884 let guard = shim_lock();
4885 let path = dir.join("fake-gh");
4886 let counter = dir.join("gh-calls");
4887 write_exec_script(
4888 &path,
4889 &format!(
4890 "#!/bin/sh\nprintf x >> {counter:?}\ncat <<'JSON'\n{stdout}\nJSON\n",
4891 counter = counter.display()
4892 ),
4893 );
4894 (path, guard, counter)
4895 }
4896
4897 /// The number of `gh` subprocesses the counting stub recorded (0 if it never
4898 /// ran) — the length of the counter file.
4899 fn gh_spawn_count(counter: &Path) -> usize {
4900 std::fs::read(counter).map_or(0, |b| b.len())
4901 }
4902
4903 /// The number of **successful** `kind: "gh"` records the #1387 choke point
4904 /// wrote to `log` — one NDJSON line per `gh` that ran to a `0` exit. Filtering
4905 /// on the exit code matches the ground-truth counter (which is written when the
4906 /// stub *runs*), so a rare failed spawn cannot desync the two.
4907 fn counted_gh_records(log: &Path) -> usize {
4908 std::fs::read_to_string(log)
4909 .unwrap_or_default()
4910 .lines()
4911 .filter(|l| l.contains(r#""kind":"gh""#) && l.contains(r#""exit_code":0"#))
4912 .count()
4913 }
4914
4915 /// A repo with a GitHub origin and one commit on `main`.
4916 fn github_repo(dir: &Path) -> Repository {
4917 github_repo_with_remote(dir, "git@github.com:rust-works/omni-dev.git")
4918 }
4919
4920 /// A repo with a specific GitHub `origin` URL and one commit on `main`, so a
4921 /// test can register **distinct** targets (owner/name) across windows.
4922 fn github_repo_with_remote(dir: &Path, url: &str) -> Repository {
4923 let repo = init_repo(dir);
4924 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
4925 repo.set_head("refs/heads/main").unwrap();
4926 repo.remote("origin", url).unwrap();
4927 repo
4928 }
4929
4930 /// A pending badge whose verdict is about `head_oid`. The commit is explicit
4931 /// because the fold downgrades a badge naming a different commit than the
4932 /// worktree's HEAD (#1337) — a fixture that got it wrong would pass for the
4933 /// wrong reason.
4934 fn pending_badge(number: u64, head_oid: &str) -> PrBadge {
4935 PrBadge {
4936 number,
4937 is_draft: false,
4938 checks: PrCheckState::Pending,
4939 url: "u".into(),
4940 head_oid: head_oid.to_string(),
4941 }
4942 }
4943
4944 /// Wraps a badge as the cache's resolution value (#1370).
4945 fn pr(badge: PrBadge) -> PrResolution {
4946 PrResolution::Pr(badge)
4947 }
4948
4949 #[test]
4950 fn pr_targets_from_snapshot_reads_github_branches_and_dedupes() {
4951 let snapshot = json!({"repos":[
4952 {
4953 "main_repo":"omni-dev",
4954 "github":{"owner":"rust-works","name":"omni-dev"},
4955 "root":"/r",
4956 // Enabled (#1376) — a not-polled repo contributes no targets (see
4957 // `pr_watch_from_snapshot_skips_a_not_polled_repo`).
4958 "polling_enabled":true,
4959 // Two worktrees on the same branch must ask once, not twice.
4960 "worktrees":[
4961 {"path":"/r","branch":"main","is_main":true,"open":true},
4962 {"path":"/w1","branch":"main","is_main":false,"open":true},
4963 {"path":"/w2","branch":"feature","is_main":false,"open":true},
4964 // Detached: no branch, so nothing to resolve.
4965 {"path":"/w3","is_main":false,"open":true}
4966 ]
4967 },
4968 {
4969 // Not on GitHub: contributes no targets at all.
4970 "main_repo":"local","root":"/l",
4971 "worktrees":[{"path":"/l","branch":"main","is_main":true,"open":true}]
4972 }
4973 ]});
4974 let targets = pr_targets_from_snapshot(&snapshot);
4975 assert_eq!(
4976 targets,
4977 vec![
4978 PrTarget {
4979 owner: "rust-works".into(),
4980 name: "omni-dev".into(),
4981 branch: "feature".into()
4982 },
4983 PrTarget {
4984 owner: "rust-works".into(),
4985 name: "omni-dev".into(),
4986 branch: "main".into()
4987 },
4988 ]
4989 );
4990 }
4991
4992 #[test]
4993 fn pr_targets_from_snapshot_is_empty_without_repos() {
4994 assert!(pr_targets_from_snapshot(&json!({"repos":[]})).is_empty());
4995 assert!(pr_targets_from_snapshot(&json!({})).is_empty());
4996 }
4997
4998 #[test]
4999 fn pr_targets_from_snapshot_skips_a_malformed_github_identity() {
5000 // Defensive: a `github` object without usable owner/name strings yields no
5001 // target rather than a half-built query.
5002 for github in [
5003 json!({}),
5004 json!({"owner": "o"}),
5005 json!({"owner": 1, "name": 2}),
5006 ] {
5007 let snapshot = json!({"repos":[{
5008 "main_repo":"r","github":github,"root":"/r","polling_enabled":true,
5009 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
5010 }]});
5011 assert!(
5012 pr_targets_from_snapshot(&snapshot).is_empty(),
5013 "{snapshot:?}"
5014 );
5015 }
5016 }
5017
5018 #[test]
5019 fn pr_watch_from_snapshot_skips_a_not_polled_repo() {
5020 // The zero-`gh` guarantee (#1376): a GitHub repo with `polling_enabled`
5021 // absent (the default-off case) or explicitly false contributes no watch,
5022 // so the poll never mentions it. Only an enabled repo is polled.
5023 for repo in [
5024 json!({
5025 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
5026 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
5027 }),
5028 json!({
5029 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
5030 "polling_enabled":false,
5031 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
5032 }),
5033 ] {
5034 let snapshot = json!({ "repos": [repo] });
5035 assert!(
5036 pr_targets_from_snapshot(&snapshot).is_empty(),
5037 "not-polled repo must yield no targets: {snapshot:?}"
5038 );
5039 }
5040 // Flipping the same repo to enabled makes it contribute.
5041 let enabled = json!({"repos":[{
5042 "main_repo":"omni-dev","github":{"owner":"o","name":"n"},"root":"/r",
5043 "polling_enabled":true,
5044 "worktrees":[{"path":"/r","branch":"main","is_main":true,"open":true}]
5045 }]});
5046 assert_eq!(pr_targets_from_snapshot(&enabled).len(), 1);
5047 }
5048
5049 #[test]
5050 fn pr_should_fetch_when_the_watch_grew_or_the_backoff_elapsed() {
5051 let backoff = Duration::from_secs(600);
5052 // Never fetched: go.
5053 assert!(pr_should_fetch(false, None, backoff));
5054 // Quiet tree, backoff not elapsed: this is the common tick — wake, look,
5055 // spend nothing.
5056 assert!(!pr_should_fetch(
5057 false,
5058 Some(Duration::from_secs(1)),
5059 backoff
5060 ));
5061 // Quiet tree, backoff elapsed: time to look again.
5062 assert!(pr_should_fetch(false, Some(backoff), backoff));
5063 assert!(pr_should_fetch(false, Some(backoff * 2), backoff));
5064 // The load-bearing case: the watch grew (a target added, or an upstream
5065 // pushed), so fetch **now** regardless of how deep the backoff had grown.
5066 // Without this a push waits out the full ceiling on a stale badge.
5067 assert!(pr_should_fetch(true, Some(Duration::ZERO), backoff));
5068 assert!(pr_should_fetch(
5069 true,
5070 Some(Duration::from_millis(1)),
5071 backoff
5072 ));
5073 }
5074
5075 #[test]
5076 fn next_pr_poll_delay_escalates_within_pending_and_backs_off_when_terminal() {
5077 let base = Duration::from_secs(10);
5078 let fresh = Some(Duration::ZERO);
5079 let stale = Some(PENDING_FAST_WINDOW);
5080 // Pending and fresh (within the fast window): hold `base`, however long we
5081 // had backed off for.
5082 assert_eq!(next_pr_poll_delay(base, base, true, fresh), base);
5083 assert_eq!(
5084 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, fresh),
5085 base
5086 );
5087 // Pending but past the fast window (a long CI run): escalate — double up to
5088 // the pending ceiling, never to the terminal one.
5089 assert_eq!(next_pr_poll_delay(base, base, true, stale), base * 2);
5090 assert_eq!(
5091 next_pr_poll_delay(PENDING_MAX_INTERVAL, base, true, stale),
5092 PENDING_MAX_INTERVAL
5093 );
5094 // Pending with nothing having moved yet (`None`) is treated as past the fast
5095 // window, so a stale-from-boot pending state does not pin `base`.
5096 assert_eq!(next_pr_poll_delay(base, base, true, None), base * 2);
5097 // Everything terminal: double…
5098 assert_eq!(next_pr_poll_delay(base, base, false, fresh), base * 2);
5099 assert_eq!(next_pr_poll_delay(base * 2, base, false, fresh), base * 4);
5100 // …up to the terminal ceiling (above the pending one), and never overflow.
5101 assert_eq!(
5102 next_pr_poll_delay(MAX_PR_POLL_INTERVAL, base, false, fresh),
5103 MAX_PR_POLL_INTERVAL
5104 );
5105 assert_eq!(
5106 next_pr_poll_delay(Duration::MAX, base, false, None),
5107 MAX_PR_POLL_INTERVAL
5108 );
5109 }
5110
5111 /// A watch on one branch with the given upstream tip.
5112 fn watch(branch: &str, upstream: Option<&str>) -> PrWatch {
5113 PrWatch {
5114 target: PrTarget {
5115 owner: "rust-works".into(),
5116 name: "omni-dev".into(),
5117 branch: branch.into(),
5118 },
5119 upstream_sha: upstream.map(str::to_string),
5120 }
5121 }
5122
5123 #[test]
5124 fn pr_watch_grew_fires_on_additions_and_pushes_but_never_on_removals() {
5125 let a = watch("a", Some("111"));
5126 let b = watch("b", Some("222"));
5127 let ab = [a.clone(), b.clone()];
5128 let just_a = std::slice::from_ref(&a);
5129 let just_b = std::slice::from_ref(&b);
5130 // Nothing new: quiet tick.
5131 assert!(!pr_watch_grew(&ab, &ab));
5132 // Addition: a new target appeared.
5133 assert!(pr_watch_grew(just_a, &ab));
5134 // Pure removal: a window/worktree went away — must NOT fetch (#1389, fix 1).
5135 assert!(!pr_watch_grew(&ab, just_a));
5136 // First sight (empty prev, from `None`): everything is new.
5137 assert!(pr_watch_grew(&[], just_a));
5138 // A push moves only the upstream — still "grew".
5139 let a_pushed = [watch("a", Some("999"))];
5140 assert!(pr_watch_grew(just_a, &a_pushed));
5141 // Gaining a target while losing another still fetches (the gain wins).
5142 assert!(pr_watch_grew(just_a, just_b));
5143 }
5144
5145 #[test]
5146 fn budget_throttled_delay_holds_the_floor_only_when_over_warn() {
5147 let base = Duration::from_secs(10);
5148 let over = RateLimitSnapshot {
5149 graphql: Some(rl_resource(90)),
5150 core: Some(rl_resource(3)),
5151 search: None,
5152 };
5153 let under = RateLimitSnapshot {
5154 graphql: Some(rl_resource(50)),
5155 core: Some(rl_resource(3)),
5156 search: None,
5157 };
5158 // No reading yet: unchanged.
5159 assert_eq!(budget_throttled_delay(base, None), base);
5160 // Under the warn threshold: unchanged.
5161 assert_eq!(budget_throttled_delay(base, Some(&under)), base);
5162 // Over: raised to at least the throttle floor…
5163 assert_eq!(
5164 budget_throttled_delay(base, Some(&over)),
5165 BUDGET_THROTTLE_INTERVAL
5166 );
5167 // …but a delay already above the floor is left alone (never shortened).
5168 let long = BUDGET_THROTTLE_INTERVAL * 2;
5169 assert_eq!(budget_throttled_delay(long, Some(&over)), long);
5170 }
5171
5172 #[test]
5173 fn pr_cache_prefs_round_trips_through_json_including_head_oid() {
5174 // The persisted cache must survive a JSON round trip with `head_oid` intact
5175 // — it is the staleness key the tree wire drops, and losing it would render
5176 // every restored badge stale (#1389, fix 4).
5177 let target = PrTarget {
5178 owner: "rust-works".into(),
5179 name: "omni-dev".into(),
5180 branch: "main".into(),
5181 };
5182 let badge = PrResolution::Pr(PrBadge {
5183 number: 1337,
5184 is_draft: true,
5185 checks: PrCheckState::Pending,
5186 url: "http://x/1337".into(),
5187 head_oid: "deadbeef".into(),
5188 });
5189 let watched = vec![watch("main", Some("abc"))];
5190 let polled_at = DateTime::parse_from_rfc3339("2026-07-21T00:00:00Z")
5191 .unwrap()
5192 .with_timezone(&Utc);
5193 let prefs = pr_cache_prefs_from(vec![(target, badge.clone())], &watched, polled_at);
5194
5195 let json = serde_json::to_vec(&prefs).unwrap();
5196 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
5197 assert_eq!(back, prefs);
5198 assert_eq!(back.polled_at, Some(polled_at));
5199 assert_eq!(back.watched[0].upstream_sha.as_deref(), Some("abc"));
5200 // The restored resolution equals the original — head_oid and all.
5201 assert_eq!(back.entries[0].resolution.clone().into_resolution(), badge);
5202 }
5203
5204 #[test]
5205 fn pr_cache_prefs_round_trip_an_explicit_no_pr_verdict() {
5206 // The explicit negative must survive persistence too: restoring `NoPr`
5207 // as "absent" would lose the #1370 distinction across a restart and
5208 // re-ask GitHub for branches already known to have no PR.
5209 let target = PrTarget {
5210 owner: "rust-works".into(),
5211 name: "omni-dev".into(),
5212 branch: "feature".into(),
5213 };
5214 let prefs = pr_cache_prefs_from(vec![(target, PrResolution::NoPr)], &[], Utc::now());
5215 let json = serde_json::to_vec(&prefs).unwrap();
5216 let back: PrCachePrefs = serde_json::from_slice(&json).unwrap();
5217 assert_eq!(back.entries[0].resolution, PersistedResolution::NoPr);
5218 assert_eq!(
5219 back.entries[0].resolution.clone().into_resolution(),
5220 PrResolution::NoPr
5221 );
5222 }
5223
5224 #[test]
5225 fn load_pr_cache_without_polled_at_restores_badges_but_no_warm_start() {
5226 // A file with verdicts but no `polled_at` (an older shape, or a
5227 // hand-edited one) still renders badges, but must not arm the warm
5228 // start: without a poll time there is nothing to age the verdicts
5229 // against, so the poller re-polls immediately (#1389, fix 4).
5230 let dir = tempfile::tempdir().unwrap();
5231 let path = dir.path().join("pr-cache.json");
5232 let target = PrTarget {
5233 owner: "rust-works".into(),
5234 name: "omni-dev".into(),
5235 branch: "main".into(),
5236 };
5237 let mut prefs = pr_cache_prefs_from(
5238 vec![(target, PrResolution::Pr(pending_badge(7, "abc")))],
5239 &[watch("main", None)],
5240 Utc::now(),
5241 );
5242 prefs.polled_at = None;
5243 write_pr_cache(&path, &prefs).unwrap();
5244
5245 let svc = WorktreesService::new();
5246 svc.load_pr_cache(path);
5247 assert!(
5248 svc.pr_cache.get("rust-works", "omni-dev", "main").is_some(),
5249 "the badge itself must still restore"
5250 );
5251 assert!(
5252 svc.pr_warm_start
5253 .lock()
5254 .unwrap_or_else(PoisonError::into_inner)
5255 .is_none(),
5256 "no poll time means a cold start, not a trusted warm one"
5257 );
5258 }
5259
5260 /// Installs a thread-local WARN-level subscriber for the duration of a
5261 /// test, so degraded-path `tracing::warn!` sites actually format their
5262 /// fields instead of short-circuiting on "nobody is listening".
5263 fn warn_subscriber() -> tracing::subscriber::DefaultGuard {
5264 tracing::subscriber::set_default(
5265 tracing_subscriber::fmt()
5266 .with_max_level(tracing::Level::WARN)
5267 .with_writer(std::io::sink)
5268 .finish(),
5269 )
5270 }
5271
5272 #[test]
5273 fn load_pr_cache_tolerates_a_corrupt_or_unreadable_file() {
5274 // The best-effort contract: a mangled cache is logged and treated as
5275 // empty — never a panic — and the path is stored regardless, so the
5276 // next successful poll rewrites a clean file (#1389, fix 4).
5277 let _trace = warn_subscriber();
5278 let dir = tempfile::tempdir().unwrap();
5279 let corrupt = dir.path().join("pr-cache.json");
5280 std::fs::write(&corrupt, b"not json").unwrap();
5281 let svc = WorktreesService::new();
5282 svc.load_pr_cache(corrupt.clone());
5283 assert!(svc.pr_cache.entries().is_empty());
5284 assert_eq!(
5285 svc.pr_cache_path
5286 .lock()
5287 .unwrap_or_else(PoisonError::into_inner)
5288 .as_deref(),
5289 Some(corrupt.as_path()),
5290 "the path must be stored even when the load fails, so persistence recovers"
5291 );
5292
5293 // A directory: `read` fails with a non-NotFound error (the distinct
5294 // "could not read" arm), with the same treated-as-empty outcome.
5295 let svc = WorktreesService::new();
5296 svc.load_pr_cache(dir.path().to_path_buf());
5297 assert!(svc.pr_cache.entries().is_empty());
5298 }
5299
5300 #[test]
5301 fn persist_pr_cache_swallows_a_write_failure() {
5302 // Best-effort: an unwritable path is logged at WARN and swallowed — the
5303 // in-memory cache stays authoritative, and losing the warm start only
5304 // costs one extra poll after the next restart.
5305 let _trace = warn_subscriber();
5306 let dir = tempfile::tempdir().unwrap();
5307 let blocker = dir.path().join("blocker");
5308 std::fs::write(&blocker, b"").unwrap();
5309 // The parent is a regular file, so creating the runtime dir must fail.
5310 let path = blocker.join("pr-cache.json");
5311 persist_pr_cache(&path, &PrStatusCache::new(), &[], Utc::now());
5312 assert!(!path.exists());
5313 // The root has no parent at all — nothing to create, and the write
5314 // itself fails (it is a directory); still swallowed.
5315 persist_pr_cache(Path::new("/"), &PrStatusCache::new(), &[], Utc::now());
5316 }
5317
5318 #[tokio::test]
5319 async fn tree_snapshot_folds_cached_pr_badges_onto_matching_branches() {
5320 let dir = tempfile::tempdir().unwrap();
5321 let repo = github_repo(dir.path());
5322 let head = repo.head().unwrap().target().unwrap().to_string();
5323 let svc = WorktreesService::new();
5324 svc.handle(
5325 "register",
5326 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5327 )
5328 .await
5329 .unwrap();
5330 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5331 // act on it, as if the user had toggled it on.
5332 svc.registry.set_polling("rust-works", "omni-dev", true);
5333
5334 // No poll has landed: the badge is absent, exactly as a pre-#1337 daemon —
5335 // and so is the negative, so "not resolved" stays distinguishable (#1370).
5336 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5337 assert!(wt.get("pr").is_none(), "{wt:?}");
5338 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5339
5340 // Seed the cache the poller writes, then re-read the tree.
5341 let mut badges = HashMap::new();
5342 badges.insert(
5343 PrTarget {
5344 owner: "rust-works".into(),
5345 name: "omni-dev".into(),
5346 branch: "main".into(),
5347 },
5348 pr(pending_badge(1337, &head)),
5349 );
5350 assert!(svc.pr_cache.replace(badges));
5351
5352 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5353 assert_eq!(wt["pr"]["number"], json!(1337));
5354 assert_eq!(wt["pr"]["checks"], json!("pending"));
5355 // camelCase on the wire, or the extension silently loses the draft marker.
5356 assert_eq!(wt["pr"]["isDraft"], json!(false));
5357 // A badge and the negative are mutually exclusive.
5358 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5359 }
5360
5361 #[tokio::test]
5362 async fn tree_snapshot_omits_a_badge_for_a_detached_worktree() {
5363 // A detached HEAD has a commit but no branch, and a badge is keyed by
5364 // branch — so there is nothing to match. It must fall through silently
5365 // rather than borrow a badge from whatever branch happens to be cached, and
5366 // rather than sink the tree.
5367 let dir = tempfile::tempdir().unwrap();
5368 let repo = github_repo(dir.path());
5369 let head = repo.head().unwrap().target().unwrap();
5370 repo.set_head_detached(head).unwrap();
5371
5372 let svc = WorktreesService::new();
5373 svc.handle(
5374 "register",
5375 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5376 )
5377 .await
5378 .unwrap();
5379 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5380 // act on it, as if the user had toggled it on.
5381 svc.registry.set_polling("rust-works", "omni-dev", true);
5382 // A badge *is* cached for `main` — the branch this worktree was on before
5383 // detaching. It must not leak onto the now-branchless row.
5384 let mut badges = HashMap::new();
5385 badges.insert(
5386 PrTarget {
5387 owner: "rust-works".into(),
5388 name: "omni-dev".into(),
5389 branch: "main".into(),
5390 },
5391 pr(pending_badge(1, &head.to_string())),
5392 );
5393 svc.pr_cache.replace(badges);
5394
5395 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5396 assert!(wt.get("branch").is_none(), "{wt:?}");
5397 // The SHA still shows — detached means no branch, not no commit.
5398 assert_eq!(
5399 wt.get("head_sha").and_then(Value::as_str),
5400 Some(head.to_string().as_str())
5401 );
5402 assert!(wt.get("pr").is_none(), "{wt:?}");
5403 // No branch means nothing was checked either: no negative on the row.
5404 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5405 }
5406
5407 #[tokio::test]
5408 async fn tree_snapshot_omits_a_badge_for_an_unmatched_branch() {
5409 let dir = tempfile::tempdir().unwrap();
5410 github_repo(dir.path());
5411 let svc = WorktreesService::new();
5412 svc.handle(
5413 "register",
5414 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5415 )
5416 .await
5417 .unwrap();
5418 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5419 // act on it, as if the user had toggled it on.
5420 svc.registry.set_polling("rust-works", "omni-dev", true);
5421 // A badge for a different branch must not leak onto `main`.
5422 let mut badges = HashMap::new();
5423 badges.insert(
5424 PrTarget {
5425 owner: "rust-works".into(),
5426 name: "omni-dev".into(),
5427 branch: "other".into(),
5428 },
5429 pr(pending_badge(1, "irrelevant")),
5430 );
5431 svc.pr_cache.replace(badges);
5432 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5433 assert!(wt.get("pr").is_none(), "{wt:?}");
5434 // An unmatched branch is unresolved, not negative.
5435 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5436 }
5437
5438 #[tokio::test]
5439 async fn tree_snapshot_reports_an_explicit_negative_for_a_branch_with_no_pr() {
5440 // The #1370 fix on the wire: a branch the poller checked and found PR-less
5441 // carries `pr_none: true` — never a sentinel `pr` object — so a client can
5442 // tell "checked, none" from "not resolved" and keep its fallback quiet.
5443 let dir = tempfile::tempdir().unwrap();
5444 github_repo(dir.path());
5445 let svc = WorktreesService::new();
5446 svc.handle(
5447 "register",
5448 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5449 )
5450 .await
5451 .unwrap();
5452 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5453 // act on it, as if the user had toggled it on.
5454 svc.registry.set_polling("rust-works", "omni-dev", true);
5455
5456 let mut resolutions = HashMap::new();
5457 resolutions.insert(
5458 PrTarget {
5459 owner: "rust-works".into(),
5460 name: "omni-dev".into(),
5461 branch: "main".into(),
5462 },
5463 PrResolution::NoPr,
5464 );
5465 assert!(svc.pr_cache.replace(resolutions));
5466
5467 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5468 assert_eq!(wt["pr_none"], json!(true));
5469 // Mutually exclusive with a badge.
5470 assert!(wt.get("pr").is_none(), "{wt:?}");
5471 }
5472
5473 #[tokio::test]
5474 async fn a_commit_does_not_drop_a_negative_resolution() {
5475 // A negative has no commit to be stale against. Dropping it when HEAD
5476 // moves would flip the row back to "unresolved" on every local commit —
5477 // re-arming every client's `gh` fallback, the very cost #1370 removes. The
5478 // poller's `moved` trigger re-checks the branch promptly instead.
5479 let dir = tempfile::tempdir().unwrap();
5480 let repo = github_repo(dir.path());
5481 let first = repo.head().unwrap().target().unwrap();
5482
5483 let svc = WorktreesService::new();
5484 svc.handle(
5485 "register",
5486 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5487 )
5488 .await
5489 .unwrap();
5490 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5491 // act on it, as if the user had toggled it on.
5492 svc.registry.set_polling("rust-works", "omni-dev", true);
5493
5494 let mut resolutions = HashMap::new();
5495 resolutions.insert(
5496 PrTarget {
5497 owner: "rust-works".into(),
5498 name: "omni-dev".into(),
5499 branch: "main".into(),
5500 },
5501 PrResolution::NoPr,
5502 );
5503 svc.pr_cache.replace(resolutions);
5504
5505 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5506 assert_eq!(wt["pr_none"], json!(true));
5507
5508 // Commit — as a push would leave things, with the cache untouched.
5509 let head = repo.find_commit(first).unwrap();
5510 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
5511
5512 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5513 assert_eq!(
5514 wt["pr_none"],
5515 json!(true),
5516 "a local commit must not drop the negative"
5517 );
5518 }
5519
5520 #[tokio::test]
5521 async fn pr_poller_asks_nothing_while_no_window_is_registered() {
5522 // The idle case — the daemon runs all day with no editor open. Point it at a
5523 // stub that fails loudly if ever spawned: a poll here would both waste
5524 // GitHub budget and, on a real `gh`, wake the radio for nothing.
5525 let bin_dir = tempfile::tempdir().unwrap();
5526 let marker = bin_dir.path().join("spawned");
5527 let fake = bin_dir.path().join("fake-gh");
5528 std::fs::write(
5529 &fake,
5530 format!("#!/bin/sh\ntouch '{}'\necho '{{}}'\n", marker.display()),
5531 )
5532 .unwrap();
5533 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5534 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5535 std::fs::set_permissions(&fake, perms).unwrap();
5536
5537 let svc = WorktreesService::new();
5538 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5539 tokio::time::sleep(Duration::from_millis(200)).await;
5540 svc.shutdown().await;
5541 assert!(
5542 !marker.exists(),
5543 "the poller must not spawn gh with no windows registered"
5544 );
5545 }
5546
5547 #[tokio::test]
5548 async fn pr_poller_survives_a_failing_gh_and_keeps_the_last_good_badges() {
5549 // Badges are decoration: an unauthenticated or broken `gh` must never sink
5550 // the tree, and one bad poll must not blank rows that were fine a second ago.
5551 let dir = tempfile::tempdir().unwrap();
5552 let repo = github_repo(dir.path());
5553 let head = repo.head().unwrap().target().unwrap().to_string();
5554 let bin_dir = tempfile::tempdir().unwrap();
5555 let fake = bin_dir.path().join("fake-gh");
5556 // Exits non-zero, exactly as `gh` does without `gh auth login`.
5557 std::fs::write(
5558 &fake,
5559 "#!/bin/sh\necho 'gh: not authenticated' >&2\nexit 1\n",
5560 )
5561 .unwrap();
5562 let mut perms = std::fs::metadata(&fake).unwrap().permissions();
5563 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
5564 std::fs::set_permissions(&fake, perms).unwrap();
5565
5566 let svc = WorktreesService::new();
5567 svc.handle(
5568 "register",
5569 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5570 )
5571 .await
5572 .unwrap();
5573 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5574 // act on it, as if the user had toggled it on.
5575 svc.registry.set_polling("rust-works", "omni-dev", true);
5576 // Seed a badge as though an earlier poll had succeeded.
5577 let mut seeded = HashMap::new();
5578 seeded.insert(
5579 PrTarget {
5580 owner: "rust-works".into(),
5581 name: "omni-dev".into(),
5582 branch: "main".into(),
5583 },
5584 pr(pending_badge(7, &head)),
5585 );
5586 svc.pr_cache.replace(seeded);
5587
5588 svc.start_pr_poller_with(Duration::from_millis(20), Duration::from_millis(10), fake);
5589 tokio::time::sleep(Duration::from_millis(200)).await;
5590
5591 // The tree still serves, and the seeded badge survived the failing polls —
5592 // which also minted no false "no PR" negatives (#1370).
5593 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5594 assert_eq!(wt["pr"]["number"], json!(7));
5595 assert!(wt.get("pr_none").is_none(), "{wt:?}");
5596 svc.shutdown().await;
5597 }
5598
5599 #[tokio::test]
5600 // The shim guard is deliberately held across the awaits below: it must span
5601 // both the stub's write *and* the poller's exec of it, since the ETXTBSY race
5602 // is against another test writing while this one forks. Safe here — only test
5603 // threads take it, never a task inside the runtime, so it cannot deadlock.
5604 // Scoped per-test rather than on the module, which would also silence the
5605 // registry lock's "never held across .await" invariant.
5606 #[allow(clippy::await_holding_lock)]
5607 async fn pr_poller_wakes_when_the_first_window_opens_after_an_idle_start() {
5608 // The normal startup order: the daemon starts at login, *before* any
5609 // editor. It therefore sees an empty tree and backs off to the 30-minute
5610 // ceiling — so unless a register wakes it, the first badge of the session
5611 // arrives up to half an hour after the window does, which reads as the
5612 // feature being broken rather than slow.
5613 let dir = tempfile::tempdir().unwrap();
5614 github_repo(dir.path());
5615 let bin_dir = tempfile::tempdir().unwrap();
5616 let (fake, _shim) = fake_gh(
5617 bin_dir.path(),
5618 r#"{"data":{"r0":{"b0":{
5619 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
5620 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
5621 ]}}},
5622 "associatedPullRequests":{"nodes":[{"number":99,"isDraft":false,"url":"u"}]}
5623 }}}}"#,
5624 );
5625
5626 let svc = WorktreesService::new();
5627 // Poller first, with nothing registered — it backs off on the empty tree.
5628 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
5629 tokio::time::sleep(Duration::from_millis(150)).await;
5630
5631 // Now an editor opens.
5632 svc.handle(
5633 "register",
5634 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5635 )
5636 .await
5637 .unwrap();
5638 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5639 // act on it, as if the user had toggled it on.
5640 svc.registry.set_polling("rust-works", "omni-dev", true);
5641
5642 // The badge must follow promptly — the register wakes the loop out of its
5643 // backoff. The deadline is orders of magnitude below the ceiling, so this
5644 // fails on the bug rather than merely being slow.
5645 let badge = tokio::time::timeout(Duration::from_secs(30), async {
5646 loop {
5647 if let Some(PrResolution::Pr(badge)) =
5648 svc.pr_cache.get("rust-works", "omni-dev", "main")
5649 {
5650 return badge;
5651 }
5652 tokio::time::sleep(Duration::from_millis(25)).await;
5653 }
5654 })
5655 .await
5656 .expect("a window opening must wake the poller out of its idle backoff");
5657 assert_eq!(badge.number, 99);
5658 svc.shutdown().await;
5659 }
5660
5661 #[tokio::test]
5662 async fn a_commit_invalidates_the_previous_verdict_without_a_poll() {
5663 // The acceptance criterion: "pushing a new commit invalidates the badge
5664 // rather than leaving the previous head's verdict standing."
5665 //
5666 // The cache still holds the verdict for the *old* commit, and the poller may
5667 // have backed off for up to half an hour. So the fold — which runs on every
5668 // snapshot — has to notice on its own, with no network call.
5669 let dir = tempfile::tempdir().unwrap();
5670 let repo = github_repo(dir.path());
5671 let first = repo.head().unwrap().target().unwrap();
5672
5673 let svc = WorktreesService::new();
5674 svc.handle(
5675 "register",
5676 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
5677 )
5678 .await
5679 .unwrap();
5680 // Polling defaults off (#1376): enable it for this repo so the poller/fold
5681 // act on it, as if the user had toggled it on.
5682 svc.registry.set_polling("rust-works", "omni-dev", true);
5683
5684 // A green verdict, correctly describing the commit currently checked out.
5685 let mut badges = HashMap::new();
5686 badges.insert(
5687 PrTarget {
5688 owner: "rust-works".into(),
5689 name: "omni-dev".into(),
5690 branch: "main".into(),
5691 },
5692 pr(PrBadge {
5693 number: 1337,
5694 is_draft: false,
5695 checks: PrCheckState::Success,
5696 url: "u".into(),
5697 head_oid: first.to_string(),
5698 }),
5699 );
5700 svc.pr_cache.replace(badges);
5701
5702 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5703 assert_eq!(
5704 wt["pr"]["checks"],
5705 json!("success"),
5706 "green for its own commit"
5707 );
5708
5709 // Now commit — as a push would leave things, with the cache untouched.
5710 let head = repo.find_commit(first).unwrap();
5711 empty_commit(&repo, Some("refs/heads/main"), &[&head], "B");
5712
5713 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
5714 assert_eq!(
5715 wt["pr"]["checks"],
5716 json!("pending"),
5717 "the previous commit's ✓ must not stand after a new commit"
5718 );
5719 // The PR itself is still shown — it is the *verdict* that is unknown, not
5720 // the PR.
5721 assert_eq!(wt["pr"]["number"], json!(1337));
5722 }
5723
5724 #[test]
5725 fn is_stale_for_compares_the_commit_the_verdict_describes() {
5726 let badge = pending_badge(1, "aaa");
5727 assert!(!badge.is_stale_for(Some("aaa")));
5728 assert!(badge.is_stale_for(Some("bbb")));
5729 // No local HEAD (unborn): nothing to compare against, so not stale.
5730 assert!(!badge.is_stale_for(None));
5731 }
5732
5733 #[test]
5734 fn pr_watch_ignores_the_head_so_a_local_commit_asks_nothing() {
5735 // #1389, fix 3. A local commit moves only the head — GitHub has not seen
5736 // it — so asking would return exactly the cached verdict, and the badge
5737 // stays correctly stale via `is_stale_for` with no network. So a snapshot
5738 // that differs *only* in `head_sha` must compare **equal** as a watch.
5739 let snap = |sha: &str| {
5740 json!({"repos":[{
5741 "main_repo":"omni-dev",
5742 "github":{"owner":"rust-works","name":"omni-dev"},
5743 "root":"/r",
5744 "polling_enabled":true,
5745 "worktrees":[{"path":"/r","branch":"main","head_sha":sha,"is_main":true,"open":true}]
5746 }]})
5747 };
5748 let before = pr_watch_from_snapshot(&snap("aaa"));
5749 let after = pr_watch_from_snapshot(&snap("bbb"));
5750 assert_eq!(before.len(), 1);
5751 assert_eq!(before[0].target, after[0].target);
5752 // The head moved, but the watch did not — no fetch trigger, no `gh` call.
5753 assert_eq!(before, after);
5754 assert!(!pr_watch_grew(&before, &after));
5755 }
5756
5757 #[test]
5758 fn pr_watch_tracks_the_upstream_so_a_push_is_visible_to_the_poller() {
5759 // #1344's bonus. A push is what *starts* the CI run a badge reports, yet
5760 // it moves no local head — so an upstream move alone must register as "go
5761 // and ask now", or the badge sits at `●` until the backoff elapses.
5762 let snap = |upstream: &str| {
5763 json!({"repos":[{
5764 "main_repo":"omni-dev",
5765 "github":{"owner":"rust-works","name":"omni-dev"},
5766 "root":"/r",
5767 "polling_enabled":true,
5768 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa",
5769 "upstream_sha":upstream,"is_main":true,"open":true}]
5770 }]})
5771 };
5772 let before = pr_watch_from_snapshot(&snap("aaa"));
5773 let after = pr_watch_from_snapshot(&snap("bbb"));
5774 // Same target, only the upstream moved — a genuine "grew" signal.
5775 assert_eq!(before.len(), 1);
5776 assert_eq!(before[0].target, after[0].target);
5777 assert_ne!(before, after);
5778 assert!(pr_watch_grew(&before, &after));
5779 // A quiet tick still asks nothing.
5780 assert_eq!(before, pr_watch_from_snapshot(&snap("aaa")));
5781 assert!(!pr_watch_grew(
5782 &before,
5783 &pr_watch_from_snapshot(&snap("aaa"))
5784 ));
5785 }
5786
5787 #[test]
5788 fn pr_watch_omits_an_absent_upstream_rather_than_erroring() {
5789 // An older daemon — or any branch tracking nothing — simply sends no
5790 // `upstream_sha`, which reads as `None` rather than failing the poll.
5791 let snap = json!({"repos":[{
5792 "main_repo":"omni-dev",
5793 "github":{"owner":"rust-works","name":"omni-dev"},
5794 "root":"/r",
5795 "polling_enabled":true,
5796 "worktrees":[{"path":"/r","branch":"main","head_sha":"aaa","is_main":true,"open":true}]
5797 }]});
5798 let watch = pr_watch_from_snapshot(&snap);
5799 assert_eq!(watch.len(), 1);
5800 assert_eq!(watch[0].upstream_sha, None);
5801 }
5802
5803 #[test]
5804 fn start_pr_poller_is_a_noop_outside_a_runtime() {
5805 let svc = WorktreesService::new();
5806 svc.start_pr_poller();
5807 assert!(svc
5808 .poller
5809 .lock()
5810 .unwrap_or_else(PoisonError::into_inner)
5811 .is_none());
5812 }
5813
5814 #[tokio::test]
5815 async fn start_pr_poller_is_idempotent_and_shutdown_stops_it() {
5816 let svc = WorktreesService::new();
5817 svc.start_pr_poller_with(
5818 Duration::from_millis(50),
5819 Duration::from_millis(10),
5820 PathBuf::from("/bin/true"),
5821 );
5822 let token = svc
5823 .poller
5824 .lock()
5825 .unwrap_or_else(PoisonError::into_inner)
5826 .as_ref()
5827 .map(|t| t.token.clone())
5828 .expect("poller started");
5829
5830 // Cancel the live task, then start again: if `start` spawned a replacement
5831 // it would orphan this one, so the token staying cancelled proves it did not.
5832 token.cancel();
5833 svc.start_pr_poller_with(
5834 Duration::from_millis(50),
5835 Duration::from_millis(10),
5836 PathBuf::from("/bin/true"),
5837 );
5838 assert!(svc
5839 .poller
5840 .lock()
5841 .unwrap_or_else(PoisonError::into_inner)
5842 .as_ref()
5843 .is_some_and(|t| t.token.is_cancelled()));
5844
5845 svc.shutdown().await;
5846 assert!(svc
5847 .poller
5848 .lock()
5849 .unwrap_or_else(PoisonError::into_inner)
5850 .is_none());
5851 }
5852
5853 // --- Rate-limit monitor (#1375) ---
5854
5855 /// A resource at `used`% of a 100-request budget.
5856 fn rl_resource(used: u64) -> RateLimitResource {
5857 RateLimitResource {
5858 used,
5859 limit: 100,
5860 remaining: 100 - used,
5861 percent: used as f64,
5862 reset: 0,
5863 }
5864 }
5865
5866 #[test]
5867 fn rate_limit_crossed_warn_fires_only_on_the_rising_edge() {
5868 let snap = |graphql: u64, core: u64| RateLimitSnapshot {
5869 graphql: Some(rl_resource(graphql)),
5870 core: Some(rl_resource(core)),
5871 search: None,
5872 };
5873 // First poll already over threshold → warn.
5874 assert!(rate_limit_crossed_warn(None, &snap(85, 3)));
5875 // First poll below → no warn.
5876 assert!(!rate_limit_crossed_warn(None, &snap(50, 3)));
5877 // Crossing upward → warn.
5878 assert!(rate_limit_crossed_warn(Some(&snap(70, 3)), &snap(85, 3)));
5879 // Staying over → no repeat warn.
5880 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(90, 3)));
5881 // Recovering below → no warn.
5882 assert!(!rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 3)));
5883 // A *different* resource crossing while the first recovers is still caught.
5884 assert!(rate_limit_crossed_warn(Some(&snap(85, 3)), &snap(50, 90)));
5885 }
5886
5887 #[test]
5888 fn start_rate_limit_poller_is_a_noop_outside_a_runtime() {
5889 let svc = WorktreesService::new();
5890 svc.start_rate_limit_poller();
5891 assert!(svc
5892 .rate_limit_poller
5893 .lock()
5894 .unwrap_or_else(PoisonError::into_inner)
5895 .is_none());
5896 }
5897
5898 #[tokio::test]
5899 async fn start_rate_limit_poller_is_idempotent_and_shutdown_stops_it() {
5900 let svc = WorktreesService::new();
5901 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
5902 let token = svc
5903 .rate_limit_poller
5904 .lock()
5905 .unwrap_or_else(PoisonError::into_inner)
5906 .as_ref()
5907 .map(|t| t.token.clone())
5908 .expect("poller started");
5909
5910 // Cancel the live task, then start again: a second start must not spawn a
5911 // replacement (which would orphan this one), so the token stays cancelled.
5912 token.cancel();
5913 svc.start_rate_limit_poller_with(Duration::from_millis(50), PathBuf::from("/bin/true"));
5914 assert!(svc
5915 .rate_limit_poller
5916 .lock()
5917 .unwrap_or_else(PoisonError::into_inner)
5918 .as_ref()
5919 .is_some_and(|t| t.token.is_cancelled()));
5920
5921 svc.shutdown().await;
5922 assert!(svc
5923 .rate_limit_poller
5924 .lock()
5925 .unwrap_or_else(PoisonError::into_inner)
5926 .is_none());
5927 }
5928
5929 #[tokio::test]
5930 // Holds the shim guard across awaits; see the note above.
5931 #[allow(clippy::await_holding_lock)]
5932 async fn rate_limit_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
5933 let bin_dir = tempfile::tempdir().unwrap();
5934 let (fake, _shim) = fake_gh(
5935 bin_dir.path(),
5936 r#"{"resources":{
5937 "graphql":{"limit":5000,"used":4100,"remaining":900,"reset":1700000000},
5938 "core":{"limit":5000,"used":27,"remaining":4973,"reset":1700000000}
5939 }}"#,
5940 );
5941 let svc = WorktreesService::new();
5942 // #1389, fix 8b: the poller only spends a `/rate_limit` call while something
5943 // is being watched — a lease makes it active without needing a window/folder.
5944 svc.registry.set_polling("rust-works", "omni-dev", true);
5945 svc.start_rate_limit_poller_with(Duration::from_millis(50), fake.clone());
5946
5947 // Each poll spawns a real subprocess; wait on a generous deadline so a
5948 // loaded machine fails honestly rather than flaking.
5949 let snap = tokio::time::timeout(Duration::from_secs(30), async {
5950 loop {
5951 if let Some(snap) = svc.rate_limit_cache.get() {
5952 return snap;
5953 }
5954 tokio::time::sleep(Duration::from_millis(25)).await;
5955 }
5956 })
5957 .await
5958 .expect("poller should populate the cache through the fake gh");
5959 assert_eq!(snap.graphql.unwrap().used, 4100);
5960 assert_eq!(snap.core.unwrap().used, 27);
5961
5962 // The reading reaches the built-in status field via the shared cache.
5963 assert!(svc.rate_limit_cache().get().is_some());
5964
5965 svc.shutdown().await;
5966 assert!(svc
5967 .rate_limit_poller
5968 .lock()
5969 .unwrap_or_else(PoisonError::into_inner)
5970 .is_none());
5971 }
5972
5973 #[tokio::test]
5974 // Holds the shim guard across awaits; see the note above.
5975 #[allow(clippy::await_holding_lock)]
5976 async fn rate_limit_poller_stays_idle_with_nothing_registered() {
5977 // #1389, fix 8b: a fully-idle daemon (no window, no lease) spends no
5978 // `/rate_limit` subprocess — the counting stub records zero spawns.
5979 let bin_dir = tempfile::tempdir().unwrap();
5980 let (fake, _shim, counter) = counting_fake_gh(
5981 bin_dir.path(),
5982 r#"{"resources":{"graphql":{"limit":5000,"used":1,"remaining":4999,"reset":1}}}"#,
5983 );
5984 let svc = WorktreesService::new();
5985 svc.start_rate_limit_poller_with(Duration::from_millis(20), fake);
5986
5987 // Give the loop several ticks; with nothing registered it must never poll.
5988 tokio::time::sleep(Duration::from_millis(300)).await;
5989 assert_eq!(
5990 gh_spawn_count(&counter),
5991 0,
5992 "idle daemon must not poll (#1389, fix 8b)"
5993 );
5994 assert!(svc.rate_limit_cache.get().is_none());
5995
5996 // Once a lease is active, the next tick populates the cache.
5997 svc.registry.set_polling("rust-works", "omni-dev", true);
5998 tokio::time::timeout(Duration::from_secs(30), async {
5999 loop {
6000 if svc.rate_limit_cache.get().is_some() {
6001 return;
6002 }
6003 tokio::time::sleep(Duration::from_millis(25)).await;
6004 }
6005 })
6006 .await
6007 .expect("an active lease should resume polling");
6008 assert!(gh_spawn_count(&counter) >= 1);
6009 svc.shutdown().await;
6010 }
6011
6012 #[tokio::test]
6013 async fn rate_limit_poller_survives_a_failing_gh() {
6014 // A missing/failing `gh` leaves the cache empty and never wedges the loop —
6015 // the degraded branch keeps the last (here, absent) reading rather than
6016 // crashing. Active via a lease so the gate (#1389, fix 8b) lets it try.
6017 let svc = WorktreesService::new();
6018 svc.registry.set_polling("rust-works", "omni-dev", true);
6019 svc.start_rate_limit_poller_with(
6020 Duration::from_millis(20),
6021 PathBuf::from("/no/such/gh/xyzzy"),
6022 );
6023 // Let it fail a few times: the cache stays empty and the task stays alive.
6024 tokio::time::sleep(Duration::from_millis(150)).await;
6025 assert!(svc.rate_limit_cache.get().is_none());
6026 assert!(
6027 svc.rate_limit_poller
6028 .lock()
6029 .unwrap_or_else(PoisonError::into_inner)
6030 .is_some(),
6031 "the loop must survive a failing gh, not panic out"
6032 );
6033 svc.shutdown().await;
6034 }
6035
6036 #[test]
6037 fn menu_prepends_the_rate_limit_line_only_when_the_cache_is_populated() {
6038 let svc = WorktreesService::new();
6039 // Empty cache → no rate-limit line (the pre-#1375 shape).
6040 let items = svc.menu().items;
6041 assert!(
6042 !items
6043 .iter()
6044 .any(|i| matches!(i, MenuItem::Label(l) if l.contains("github:"))),
6045 "no github line before the first poll"
6046 );
6047
6048 // Populate the cache → the first item is the rate-limit status line.
6049 svc.rate_limit_cache.replace(RateLimitSnapshot {
6050 graphql: Some(rl_resource(82)),
6051 core: Some(rl_resource(3)),
6052 search: None,
6053 });
6054 let items = svc.menu().items;
6055 assert!(
6056 matches!(items.first(), Some(MenuItem::Label(l)) if l.starts_with("github: graphql 82%")),
6057 "expected the github line first, got {items:?}"
6058 );
6059 assert!(
6060 matches!(items.get(1), Some(MenuItem::Separator)),
6061 "expected a separator after the github line"
6062 );
6063 }
6064
6065 #[tokio::test]
6066 // Holds the shim guard across awaits; see the note above.
6067 #[allow(clippy::await_holding_lock)]
6068 async fn pr_poller_resolves_via_gh_populates_the_cache_and_stops_on_shutdown() {
6069 let dir = tempfile::tempdir().unwrap();
6070 github_repo(dir.path());
6071 let bin_dir = tempfile::tempdir().unwrap();
6072 // One repo, one branch → aliases r0/b0. A still-running check so the badge
6073 // stays pending and the loop keeps its fast cadence.
6074 let (fake, _shim) = fake_gh(
6075 bin_dir.path(),
6076 r#"{"data":{"r0":{"b0":{
6077 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6078 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6079 ]}}},
6080 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
6081 }}}}"#,
6082 );
6083 let svc = WorktreesService::new();
6084 svc.handle(
6085 "register",
6086 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6087 )
6088 .await
6089 .unwrap();
6090 // Polling defaults off (#1376): enable it for this repo so the poller/fold
6091 // act on it, as if the user had toggled it on.
6092 svc.registry.set_polling("rust-works", "omni-dev", true);
6093 svc.start_pr_poller_with(
6094 Duration::from_millis(50),
6095 Duration::from_millis(10),
6096 fake.clone(),
6097 );
6098
6099 // Wait on a generous wall-clock deadline: each poll spawns a real
6100 // subprocess, and under a loaded machine (a full `build.sh` runs a build
6101 // and clippy alongside) a tight budget flakes rather than fails honestly.
6102 let badge = tokio::time::timeout(Duration::from_secs(30), async {
6103 loop {
6104 if let Some(PrResolution::Pr(badge)) =
6105 svc.pr_cache.get("rust-works", "omni-dev", "main")
6106 {
6107 return badge;
6108 }
6109 tokio::time::sleep(Duration::from_millis(25)).await;
6110 }
6111 })
6112 .await
6113 .expect("poller should resolve a badge through the fake gh");
6114 assert_eq!(badge.number, 1337);
6115 assert_eq!(badge.checks, crate::pr_status::PrCheckState::Pending);
6116
6117 // The badge reaches the wire the windows actually read.
6118 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6119 assert_eq!(wt["pr"]["number"], json!(1337));
6120
6121 // And the loop is quiescent after shutdown: the generation must stop moving.
6122 svc.shutdown().await;
6123 let generation = svc.registry.change_generation();
6124 tokio::time::sleep(Duration::from_millis(120)).await;
6125 assert_eq!(
6126 svc.registry.change_generation(),
6127 generation,
6128 "no bumps after shutdown"
6129 );
6130 }
6131
6132 #[tokio::test]
6133 // Holds the shim guard across awaits; see the note above.
6134 #[allow(clippy::await_holding_lock)]
6135 async fn pr_poll_folds_its_graphql_budget_into_the_rate_limit_cache() {
6136 // #1389, fix 8a: every PR poll carries a free graphql budget reading, which
6137 // the poller folds into the shared cache — so the graphql figure stays fresh
6138 // without a standalone `/rate_limit` call.
6139 let dir = tempfile::tempdir().unwrap();
6140 github_repo(dir.path());
6141 let bin_dir = tempfile::tempdir().unwrap();
6142 let (fake, _shim) = fake_gh(
6143 bin_dir.path(),
6144 r#"{"data":{
6145 "rateLimit":{"limit":5000,"cost":1,"remaining":4877,"used":123,
6146 "resetAt":"2026-07-21T16:00:00Z"},
6147 "r0":{"b0":{
6148 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6149 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6150 ]}}},
6151 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
6152 }}
6153 }}"#,
6154 );
6155 let svc = WorktreesService::new();
6156 svc.handle(
6157 "register",
6158 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6159 )
6160 .await
6161 .unwrap();
6162 svc.registry.set_polling("rust-works", "omni-dev", true);
6163 // No rate-limit poller started: the only writer of the cache is the PR poll's
6164 // folded-in budget, so a populated graphql reading proves fix 8a.
6165 svc.start_pr_poller_with(
6166 Duration::from_millis(50),
6167 Duration::from_millis(10),
6168 fake.clone(),
6169 );
6170
6171 let graphql = tokio::time::timeout(Duration::from_secs(30), async {
6172 loop {
6173 if let Some(g) = svc.rate_limit_cache.get().and_then(|s| s.graphql) {
6174 return g;
6175 }
6176 tokio::time::sleep(Duration::from_millis(25)).await;
6177 }
6178 })
6179 .await
6180 .expect("the PR poll should fold its budget into the cache");
6181 assert_eq!(graphql.used, 123);
6182 assert_eq!(graphql.limit, 5000);
6183 assert_eq!(graphql.remaining, 4877);
6184 svc.shutdown().await;
6185 }
6186
6187 #[tokio::test]
6188 // Holds the shim guard across awaits; see the note above.
6189 #[allow(clippy::await_holding_lock)]
6190 async fn pr_poll_counts_every_gh_call_exactly_once() {
6191 // #1389's non-negotiable constraint (#1387): fewer calls, but every call
6192 // still counted. Compares the ground-truth number of `gh` subprocesses the
6193 // poll actually spawned against the number of successful `kind:"gh"` records
6194 // the counted `run_gh` choke point wrote — they must be equal, so a future
6195 // refactor cannot add an uncounted `gh` path (counted < spawns) without
6196 // failing here.
6197 let dir = tempfile::tempdir().unwrap();
6198 github_repo(dir.path());
6199 let bin_dir = tempfile::tempdir().unwrap();
6200 let (fake, _shim, counter) = counting_fake_gh(
6201 bin_dir.path(),
6202 r#"{"data":{"r0":{"b0":{
6203 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6204 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6205 ]}}},
6206 "associatedPullRequests":{"nodes":[{"number":1337,"isDraft":false,"url":"http://x/1337"}]}
6207 }}}}"#,
6208 );
6209 let log = bin_dir.path().join("log.jsonl");
6210 std::env::set_var("OMNI_DEV_LOG_FILE", &log);
6211
6212 let svc = WorktreesService::new();
6213 svc.handle(
6214 "register",
6215 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6216 )
6217 .await
6218 .unwrap();
6219 svc.registry.set_polling("rust-works", "omni-dev", true);
6220 svc.start_pr_poller_with(Duration::from_millis(30), Duration::from_millis(10), fake);
6221
6222 // Wait for at least one fetch to land.
6223 tokio::time::timeout(Duration::from_secs(30), async {
6224 loop {
6225 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
6226 return;
6227 }
6228 tokio::time::sleep(Duration::from_millis(25)).await;
6229 }
6230 })
6231 .await
6232 .expect("poller should fetch through the fake gh");
6233
6234 // Stop the loop so both counts are final (no in-flight gh), then compare.
6235 svc.shutdown().await;
6236 let spawns = gh_spawn_count(&counter);
6237 let counted = counted_gh_records(&log);
6238 std::env::remove_var("OMNI_DEV_LOG_FILE");
6239 assert!(
6240 spawns >= 1,
6241 "the poll should have spent at least one gh call"
6242 );
6243 assert_eq!(
6244 counted, spawns,
6245 "#1387: every gh call ({spawns}) must be counted exactly once, got {counted}"
6246 );
6247 }
6248
6249 #[tokio::test]
6250 // Holds the shim guard across awaits; see the note above.
6251 #[allow(clippy::await_holding_lock)]
6252 async fn pr_poll_debounces_a_registration_storm_into_one_fetch() {
6253 // #1389, fix 2: a burst of registrations (a VS Code restart re-registering
6254 // its windows) that each *grow* the watch must collapse into ONE fetch on
6255 // the final set, not one per window. Two distinct repos appear back-to-back
6256 // inside the debounce window; a debounce-free loop would fetch twice.
6257 let dir_a = tempfile::tempdir().unwrap();
6258 let dir_b = tempfile::tempdir().unwrap();
6259 github_repo(dir_a.path()); // rust-works/omni-dev → alias r0
6260 github_repo_with_remote(dir_b.path(), "git@github.com:rust-works/other-repo.git"); // r1
6261 let bin_dir = tempfile::tempdir().unwrap();
6262 // Both terminal, so no fast pending cadence can add a second fetch.
6263 let (fake, _shim, counter) = counting_fake_gh(
6264 bin_dir.path(),
6265 r#"{"data":{
6266 "r0":{"b0":{"target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
6267 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6268 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"http://x/1"}]}}},
6269 "r1":{"b0":{"target":{"oid":"b","statusCheckRollup":{"contexts":{"nodes":[
6270 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6271 "associatedPullRequests":{"nodes":[{"number":2,"isDraft":false,"url":"http://x/2"}]}}}
6272 }}"#,
6273 );
6274 let svc = WorktreesService::new();
6275 // Enable polling for both before they register, so the first snapshot after
6276 // the storm already counts them.
6277 svc.registry.set_polling("rust-works", "omni-dev", true);
6278 svc.registry.set_polling("rust-works", "other-repo", true);
6279 // `base` far larger than the test so only the storm — never the cadence —
6280 // can trigger a fetch; a generous debounce so the two registers land inside
6281 // one settle window even on a loaded machine.
6282 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(200), fake);
6283 // The burst: both windows register back-to-back.
6284 svc.handle(
6285 "register",
6286 json!({ "key": "a", "folders": [dir_a.path()], "repo": "omni-dev" }),
6287 )
6288 .await
6289 .unwrap();
6290 // A beat between the two, so the first bump has (all but certainly)
6291 // woken the poller into its settle window before the second arrives —
6292 // exercising the debounce *restart*, not just a single coalesced wake.
6293 tokio::time::sleep(Duration::from_millis(50)).await;
6294 svc.handle(
6295 "register",
6296 json!({ "key": "b", "folders": [dir_b.path()], "repo": "other-repo" }),
6297 )
6298 .await
6299 .unwrap();
6300
6301 // Wait until both badges resolve — proving the single fetch covered the full
6302 // final set, not just the first window.
6303 tokio::time::timeout(Duration::from_secs(30), async {
6304 loop {
6305 let a = svc.pr_cache.get("rust-works", "omni-dev", "main").is_some();
6306 let b = svc
6307 .pr_cache
6308 .get("rust-works", "other-repo", "main")
6309 .is_some();
6310 if a && b {
6311 return;
6312 }
6313 tokio::time::sleep(Duration::from_millis(25)).await;
6314 }
6315 })
6316 .await
6317 .expect("the debounced fetch should resolve both repos");
6318
6319 svc.shutdown().await;
6320 assert_eq!(
6321 gh_spawn_count(&counter),
6322 1,
6323 "the registration storm must collapse into exactly one fetch (#1389, fix 2)"
6324 );
6325 }
6326
6327 #[tokio::test]
6328 // Holds the shim guard across awaits; see the note above.
6329 #[allow(clippy::await_holding_lock)]
6330 async fn pr_poll_debounce_deadline_bounds_a_steady_drip_of_changes() {
6331 // The settle loop is bounded: a drip of registry bumps, each landing
6332 // inside the debounce window, must not postpone the poll forever — the
6333 // overall deadline (4× the debounce) forces the snapshot mid-storm
6334 // (#1389, fix 2).
6335 let dir = tempfile::tempdir().unwrap();
6336 github_repo(dir.path());
6337 let bin_dir = tempfile::tempdir().unwrap();
6338 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6339 let svc = WorktreesService::new();
6340 svc.registry.set_polling("rust-works", "omni-dev", true);
6341 // `base` far larger than the test, so only the grew-trigger — released
6342 // by the deadline — can fetch.
6343 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(50), fake);
6344 let register = json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" });
6345 svc.handle("register", register.clone()).await.unwrap();
6346 // Re-register (an upsert, but still a bump) every 25ms — inside the
6347 // 50ms debounce — for well past the 200ms deadline. A deadline-free
6348 // loop would still be settling when the drip ends.
6349 for _ in 0..24 {
6350 tokio::time::sleep(Duration::from_millis(25)).await;
6351 svc.handle("register", register.clone()).await.unwrap();
6352 }
6353 let spawned_mid_drip = gh_spawn_count(&counter);
6354 svc.shutdown().await;
6355 assert!(
6356 spawned_mid_drip >= 1,
6357 "the deadline must force a fetch while the drip is still running (#1389, fix 2)"
6358 );
6359 }
6360
6361 #[tokio::test]
6362 // Holds the shim guard across awaits; see the note above.
6363 #[allow(clippy::await_holding_lock)]
6364 async fn pr_poller_skips_the_immediate_fetch_when_the_warm_cache_is_fresh() {
6365 // #1389, fix 4: a daemon restart within the backoff window serves badges
6366 // from the persisted cache and spends **no** gh call, because every current
6367 // target already has a fresh verdict.
6368 let dir = tempfile::tempdir().unwrap();
6369 let repo = github_repo(dir.path());
6370 let head = repo.head().unwrap().target().unwrap().to_string();
6371 let bin_dir = tempfile::tempdir().unwrap();
6372 // If the poller wrongly fetched, this empty reply would still spawn the stub
6373 // and bump the counter — which is exactly what the assertion catches.
6374 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6375
6376 // Persist a fresh cache the way the previous daemon would have: a badge for
6377 // `main` whose verdict is about the current head (so it is not stale),
6378 // watched at `(main, no upstream)`, resolved just now.
6379 let cache_path = bin_dir.path().join("pr-cache.json");
6380 let target = PrTarget {
6381 owner: "rust-works".into(),
6382 name: "omni-dev".into(),
6383 branch: "main".into(),
6384 };
6385 let prefs = pr_cache_prefs_from(
6386 vec![(target, PrResolution::Pr(pending_badge(1337, &head)))],
6387 &[watch("main", None)],
6388 Utc::now(),
6389 );
6390 write_pr_cache(&cache_path, &prefs).unwrap();
6391
6392 let svc = WorktreesService::new();
6393 svc.load_pr_cache(cache_path);
6394 svc.registry.set_polling("rust-works", "omni-dev", true);
6395 svc.handle(
6396 "register",
6397 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6398 )
6399 .await
6400 .unwrap();
6401 // `base` far larger than the test: the only fetch that could happen is the
6402 // immediate one we expect the warm cache to skip.
6403 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
6404
6405 // The restored badge renders on the wire without a gh call.
6406 let number = tokio::time::timeout(Duration::from_secs(30), async {
6407 loop {
6408 let tree = svc.handle("tree", Value::Null).await.unwrap();
6409 if let Some(n) = repos_of(&tree)
6410 .first()
6411 .and_then(|r| r["worktrees"][0]["pr"]["number"].as_u64())
6412 {
6413 return n;
6414 }
6415 tokio::time::sleep(Duration::from_millis(25)).await;
6416 }
6417 })
6418 .await
6419 .expect("the restored badge should render from the warm cache");
6420 assert_eq!(number, 1337);
6421
6422 // Let the poller run a while, then confirm it stayed quiet.
6423 tokio::time::sleep(Duration::from_millis(300)).await;
6424 svc.shutdown().await;
6425 assert_eq!(
6426 gh_spawn_count(&counter),
6427 0,
6428 "a fresh warm cache must skip the immediate re-poll (#1389, fix 4)"
6429 );
6430 }
6431
6432 #[tokio::test]
6433 // Holds the shim guard across awaits; see the note above.
6434 #[allow(clippy::await_holding_lock)]
6435 async fn pr_poller_persists_fresh_verdicts_for_the_next_warm_start() {
6436 // #1389, fix 4, write side (the twin of the skip test above, which reads
6437 // a hand-written file): a successful resolve persists the cache —
6438 // creating the runtime dir if needed — so the *next* daemon restart
6439 // warm-starts from it.
6440 let dir = tempfile::tempdir().unwrap();
6441 github_repo(dir.path());
6442 let bin_dir = tempfile::tempdir().unwrap();
6443 let (fake, _shim) = fake_gh(
6444 bin_dir.path(),
6445 r#"{"data":{"r0":{"b0":{
6446 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6447 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}]}}},
6448 "associatedPullRequests":{"nodes":[{"number":41,"isDraft":false,"url":"u"}]}
6449 }}}}"#,
6450 );
6451 let svc = WorktreesService::new();
6452 // No file yet, and no parent dir either: the load takes the benign
6453 // NotFound arm, and the write must create the `0700` runtime dir.
6454 let cache_path = bin_dir.path().join("runtime").join("pr-cache.json");
6455 svc.load_pr_cache(cache_path.clone());
6456 svc.registry.set_polling("rust-works", "omni-dev", true);
6457 svc.handle(
6458 "register",
6459 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6460 )
6461 .await
6462 .unwrap();
6463 svc.start_pr_poller_with(Duration::from_millis(50), Duration::from_millis(10), fake);
6464
6465 // A partially-written or not-yet-written file simply retries: only a
6466 // fully parseable cache ends the wait.
6467 let prefs = tokio::time::timeout(Duration::from_secs(30), async {
6468 loop {
6469 if let Ok(bytes) = std::fs::read(&cache_path) {
6470 if let Ok(prefs) = serde_json::from_slice::<PrCachePrefs>(&bytes) {
6471 if !prefs.entries.is_empty() {
6472 return prefs;
6473 }
6474 }
6475 }
6476 tokio::time::sleep(Duration::from_millis(25)).await;
6477 }
6478 })
6479 .await
6480 .expect("a successful resolve should persist the cache file");
6481 svc.shutdown().await;
6482
6483 assert_eq!(prefs.entries[0].target.branch, "main");
6484 assert!(
6485 matches!(&prefs.entries[0].resolution, PersistedResolution::Pr(b) if b.number == 41),
6486 "{:?}",
6487 prefs.entries[0].resolution
6488 );
6489 assert_eq!(
6490 prefs.watched,
6491 vec![PersistedWatch {
6492 target: prefs.entries[0].target.clone(),
6493 upstream_sha: None
6494 }]
6495 );
6496 assert!(
6497 prefs.polled_at.is_some(),
6498 "the poll time is what ages the next warm start"
6499 );
6500 }
6501
6502 #[tokio::test]
6503 // Holds the shim guard across awaits; see the note above.
6504 #[allow(clippy::await_holding_lock)]
6505 async fn open_prs_op_serves_from_gh_then_dedupes_within_the_ttl() {
6506 // #1389, fix 7: the daemon serves "Open Pull Request…" so N windows dedupe
6507 // to one counted `gh pr list` per repo. A generous TTL, so the second call
6508 // is served from the cache and spawns **no** second `gh` — the whole point.
6509 let bin_dir = tempfile::tempdir().unwrap();
6510 let (fake, _shim, counter) = counting_fake_gh(
6511 bin_dir.path(),
6512 r#"[{"number":42,"title":"T","url":"http://x/42","headRefName":"feat",
6513 "baseRefName":"main","isDraft":false,"state":"OPEN","author":{"login":"me"}}]"#,
6514 );
6515 let svc = WorktreesService::new();
6516
6517 let prs = svc
6518 .open_prs_with("rust-works", "omni-dev", fake.clone())
6519 .await
6520 .expect("gh pr list should resolve");
6521 assert_eq!(prs.len(), 1);
6522 assert_eq!(prs[0]["number"], json!(42));
6523 assert_eq!(prs[0]["url"], json!("http://x/42"));
6524 assert_eq!(gh_spawn_count(&counter), 1, "first call spends one gh");
6525
6526 // A second window asking the same repo is served from the shared cache.
6527 let again = svc
6528 .open_prs_with("rust-works", "omni-dev", fake.clone())
6529 .await
6530 .expect("cache hit should resolve");
6531 assert_eq!(again, prs);
6532 assert_eq!(
6533 gh_spawn_count(&counter),
6534 1,
6535 "the second lookup must dedupe to the cached result, not a new gh (#1389, fix 7)"
6536 );
6537
6538 // The op wrapper shapes the reply and validates the payload.
6539 let reply = svc
6540 .handle(
6541 "open-prs",
6542 json!({ "owner": "rust-works", "name": "omni-dev" }),
6543 )
6544 .await
6545 .expect("open-prs op should route");
6546 assert_eq!(reply["pull_requests"][0]["number"], json!(42));
6547 assert!(svc
6548 .handle("open-prs", json!({ "owner": " ", "name": "x" }))
6549 .await
6550 .is_err());
6551 }
6552
6553 #[test]
6554 fn open_pr_list_surfaces_a_missing_binary_a_failed_run_and_bad_json() {
6555 // The three degraded shapes a real `gh` presents — not installed, a
6556 // nonzero exit (auth/network), and output that is not the JSON array
6557 // the menu indexes into — must each be a distinct, actionable error
6558 // rather than a panic or a silently empty list (#1389, fix 7).
6559 let err = open_pr_list(Path::new("/nonexistent/gh"), "rust-works/omni-dev").unwrap_err();
6560 assert!(
6561 err.to_string().contains("is the GitHub CLI installed"),
6562 "{err:#}"
6563 );
6564
6565 let bin_dir = tempfile::tempdir().unwrap();
6566 let _guard = shim_lock();
6567 let failing = bin_dir.path().join("fake-gh-fails");
6568 write_exec_script(&failing, "#!/bin/sh\necho 'boom' >&2\nexit 1\n");
6569 let err = open_pr_list(&failing, "rust-works/omni-dev").unwrap_err();
6570 assert!(err.to_string().contains("gh pr list failed"), "{err:#}");
6571 assert!(err.to_string().contains("boom"), "{err:#}");
6572
6573 let object = bin_dir.path().join("fake-gh-object");
6574 write_exec_script(&object, "#!/bin/sh\necho '{}'\n");
6575 let err = open_pr_list(&object, "rust-works/omni-dev").unwrap_err();
6576 assert!(
6577 err.to_string().contains("did not return a JSON array"),
6578 "{err:#}"
6579 );
6580 }
6581
6582 #[tokio::test]
6583 // Holds the shim guard across awaits; see the note above.
6584 #[allow(clippy::await_holding_lock)]
6585 async fn pr_poller_throttles_when_the_budget_is_over_warn() {
6586 // #1389, fix 6: over the ~80% warn threshold the poller holds its stretched
6587 // cadence and ignores even a grown watch, so no runaway can drain the shared
6588 // budget. A recent warm `last_poll` is seeded so the "first sight always
6589 // fetches" base case cannot mask the throttle — the only thing that could
6590 // fetch here is the grew-trigger, which the throttle suppresses.
6591 let dir = tempfile::tempdir().unwrap();
6592 github_repo(dir.path());
6593 let bin_dir = tempfile::tempdir().unwrap();
6594 let (fake, _shim, counter) = counting_fake_gh(bin_dir.path(), "{}");
6595
6596 let svc = WorktreesService::new();
6597 // Warm start with an *empty* watch but a fresh poll time: the registered
6598 // repo then reads as a grown watch, while `last_poll` is recent enough that
6599 // only the grew-trigger — not an elapsed backoff — could drive a fetch.
6600 *svc.pr_warm_start
6601 .lock()
6602 .unwrap_or_else(PoisonError::into_inner) = Some(PrWarmStart {
6603 watched: vec![],
6604 polled_at: Utc::now(),
6605 });
6606 // Budget over the warn threshold before the poller starts.
6607 svc.rate_limit_cache.replace(RateLimitSnapshot {
6608 graphql: Some(rl_resource(90)),
6609 core: Some(rl_resource(3)),
6610 search: None,
6611 });
6612 svc.registry.set_polling("rust-works", "omni-dev", true);
6613 svc.handle(
6614 "register",
6615 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6616 )
6617 .await
6618 .unwrap();
6619 // `base` far larger than the test, so a fetch could only come from the
6620 // grew-trigger the throttle is meant to suppress.
6621 svc.start_pr_poller_with(Duration::from_secs(30), Duration::from_millis(10), fake);
6622
6623 // Let the poller wake on the registration and run a while.
6624 tokio::time::sleep(Duration::from_millis(300)).await;
6625 svc.shutdown().await;
6626 assert_eq!(
6627 gh_spawn_count(&counter),
6628 0,
6629 "over WARN_PERCENT the poller must not fetch a grown watch (#1389, fix 6)"
6630 );
6631 }
6632
6633 #[tokio::test]
6634 // Holds the shim guard across awaits; see the note above.
6635 #[allow(clippy::await_holding_lock)]
6636 async fn pr_poller_bumps_only_when_a_verdict_actually_moves() {
6637 // The diff-and-drop contract: an unchanged poll must not bump, or every
6638 // window re-renders on every tick — the cost this design exists to avoid.
6639 let dir = tempfile::tempdir().unwrap();
6640 github_repo(dir.path());
6641 let bin_dir = tempfile::tempdir().unwrap();
6642 let (fake, _shim) = fake_gh(
6643 bin_dir.path(),
6644 r#"{"data":{"r0":{"b0":{
6645 "target":{"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
6646 {"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null}
6647 ]}}},
6648 "associatedPullRequests":{"nodes":[{"number":1,"isDraft":false,"url":"u"}]}
6649 }}}}"#,
6650 );
6651 let svc = WorktreesService::new();
6652 svc.handle(
6653 "register",
6654 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6655 )
6656 .await
6657 .unwrap();
6658 // Polling defaults off (#1376): enable it for this repo so the poller/fold
6659 // act on it, as if the user had toggled it on.
6660 svc.registry.set_polling("rust-works", "omni-dev", true);
6661 svc.start_pr_poller_with(
6662 Duration::from_millis(50),
6663 Duration::from_millis(10),
6664 fake.clone(),
6665 );
6666
6667 tokio::time::timeout(Duration::from_secs(30), async {
6668 loop {
6669 if svc.pr_cache.get("rust-works", "omni-dev", "main").is_some() {
6670 return;
6671 }
6672 tokio::time::sleep(Duration::from_millis(25)).await;
6673 }
6674 })
6675 .await
6676 .expect("poller should resolve a badge through the fake gh");
6677 // The fake always answers identically, so after the first resolve every
6678 // subsequent poll is a no-change and must leave the generation alone.
6679 let settled = svc.registry.change_generation();
6680 tokio::time::sleep(Duration::from_millis(150)).await;
6681 assert_eq!(
6682 svc.registry.change_generation(),
6683 settled,
6684 "an unchanged poll must not bump the change-notify"
6685 );
6686 svc.shutdown().await;
6687 }
6688
6689 #[tokio::test]
6690 // Holds the shim guard across awaits; see the note above.
6691 #[allow(clippy::await_holding_lock)]
6692 async fn pr_poller_resolves_a_negative_through_gh_and_bumps_once() {
6693 // The negative twin of `pr_poller_bumps_only_when_a_verdict_actually_moves`
6694 // (#1370): a PR-less branch resolves to NoPr end-to-end, reaches the wire
6695 // as `pr_none`, and — since the answer never changes — bumps the
6696 // change-notify only for the poll that first delivered it.
6697 let dir = tempfile::tempdir().unwrap();
6698 github_repo(dir.path());
6699 let bin_dir = tempfile::tempdir().unwrap();
6700 let (fake, _shim) = fake_gh(
6701 bin_dir.path(),
6702 r#"{"data":{"r0":{"b0":{
6703 "target":{"oid":"abc","statusCheckRollup":null},
6704 "associatedPullRequests":{"nodes":[]}
6705 }}}}"#,
6706 );
6707 let svc = WorktreesService::new();
6708 svc.handle(
6709 "register",
6710 json!({ "key": "w", "folders": [dir.path()], "repo": "omni-dev" }),
6711 )
6712 .await
6713 .unwrap();
6714 // Polling defaults off (#1376): enable it for this repo so the poller/fold
6715 // act on it, as if the user had toggled it on.
6716 svc.registry.set_polling("rust-works", "omni-dev", true);
6717 svc.start_pr_poller_with(
6718 Duration::from_millis(50),
6719 Duration::from_millis(10),
6720 fake.clone(),
6721 );
6722
6723 tokio::time::timeout(Duration::from_secs(30), async {
6724 loop {
6725 if svc.pr_cache.get("rust-works", "omni-dev", "main") == Some(PrResolution::NoPr) {
6726 return;
6727 }
6728 tokio::time::sleep(Duration::from_millis(25)).await;
6729 }
6730 })
6731 .await
6732 .expect("poller should resolve the negative through the fake gh");
6733
6734 // The negative reaches the wire the windows actually read.
6735 let wt = &repos_of(&svc.handle("tree", Value::Null).await.unwrap())[0]["worktrees"][0];
6736 assert_eq!(wt["pr_none"], json!(true));
6737 assert!(wt.get("pr").is_none(), "{wt:?}");
6738
6739 // Identical re-polls of the same negative must not bump.
6740 let settled = svc.registry.change_generation();
6741 tokio::time::sleep(Duration::from_millis(150)).await;
6742 assert_eq!(
6743 svc.registry.change_generation(),
6744 settled,
6745 "an unchanged negative must not bump the change-notify"
6746 );
6747 svc.shutdown().await;
6748 }
6749
6750 #[test]
6751 fn sync_indicator_formats_only_with_upstream() {
6752 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
6753 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
6754 assert_eq!(sync_indicator(None, None), None);
6755 // A partial pair (no real upstream) yields nothing.
6756 assert_eq!(sync_indicator(Some(1), None), None);
6757 }
6758
6759 #[tokio::test]
6760 async fn list_enriches_entries_with_git_status() {
6761 let dir = tempfile::tempdir().unwrap();
6762 let repo = init_repo(dir.path());
6763 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6764 repo.set_head("refs/heads/main").unwrap();
6765
6766 let svc = WorktreesService::new();
6767 svc.handle(
6768 "register",
6769 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6770 )
6771 .await
6772 .unwrap();
6773 let payload = svc.handle("list", Value::Null).await.unwrap();
6774 let windows = windows_of(&payload);
6775 assert_eq!(windows.len(), 1);
6776 assert_eq!(
6777 windows[0].get("branch").and_then(Value::as_str),
6778 Some("main")
6779 );
6780 // No upstream configured → the ahead/behind keys are absent, not zero.
6781 assert!(windows[0].get("ahead").is_none());
6782 assert!(windows[0].get("behind").is_none());
6783 // The main repo name is enriched onto the entry.
6784 assert_eq!(
6785 windows[0].get("main_repo").and_then(Value::as_str),
6786 dir.path().file_name().and_then(|n| n.to_str())
6787 );
6788
6789 // A non-repo folder is still listed, just without a branch or main repo.
6790 let plain = tempfile::tempdir().unwrap();
6791 svc.handle(
6792 "register",
6793 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
6794 )
6795 .await
6796 .unwrap();
6797 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
6798 let w2 = windows
6799 .iter()
6800 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
6801 .unwrap();
6802 assert!(w2.get("branch").is_none());
6803 assert!(w2.get("main_repo").is_none());
6804 }
6805
6806 #[test]
6807 fn window_label_prefers_git_branch_over_title() {
6808 let dir = tempfile::tempdir().unwrap();
6809 let repo = init_repo(dir.path());
6810 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6811 repo.set_head("refs/heads/main").unwrap();
6812 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
6813 let entry = WindowEntry {
6814 key: "k".to_string(),
6815 folders: vec![dir.path().to_path_buf()],
6816 // Both the companion `repo` and `title` are overridden by the
6817 // git-derived main repo name and computed branch.
6818 repo: Some("companion-repo".to_string()),
6819 title: Some("ignored title".to_string()),
6820 pid: None,
6821 last_seen: Utc::now(),
6822 };
6823 // Main checkout: `repo · branch`, and with no upstream there is no sync.
6824 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
6825 }
6826
6827 #[tokio::test]
6828 async fn list_includes_ahead_behind_for_tracking_branch() {
6829 let dir = tempfile::tempdir().unwrap();
6830 let _repo = diverging_repo(dir.path());
6831
6832 let svc = WorktreesService::new();
6833 svc.handle(
6834 "register",
6835 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
6836 )
6837 .await
6838 .unwrap();
6839 let payload = svc.handle("list", Value::Null).await.unwrap();
6840 let windows = windows_of(&payload);
6841 // A tracking branch serializes branch plus both divergence counts.
6842 assert_eq!(
6843 windows[0].get("branch").and_then(Value::as_str),
6844 Some("main")
6845 );
6846 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
6847 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
6848 }
6849
6850 #[test]
6851 fn window_label_includes_sync_for_tracking_branch() {
6852 let dir = tempfile::tempdir().unwrap();
6853 let _repo = diverging_repo(dir.path());
6854 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
6855 let entry = WindowEntry {
6856 key: "k".to_string(),
6857 folders: vec![dir.path().to_path_buf()],
6858 repo: Some("companion-repo".to_string()),
6859 title: None,
6860 pid: None,
6861 last_seen: Utc::now(),
6862 };
6863 // A tracking branch appends the `(+ahead -behind)` sync indicator.
6864 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
6865 }
6866
6867 /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
6868 /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
6869 /// <wt_path>`.
6870 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
6871 let commit = repo.find_commit(base).unwrap();
6872 repo.branch(branch, &commit, false).unwrap();
6873 let reference = repo
6874 .find_reference(&format!("refs/heads/{branch}"))
6875 .unwrap();
6876 let mut opts = git2::WorktreeAddOptions::new();
6877 opts.reference(Some(&reference));
6878 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
6879 }
6880
6881 #[test]
6882 fn git_status_marks_linked_worktree_and_names_parent_repo() {
6883 let main_dir = tempfile::tempdir().unwrap();
6884 let repo = init_repo(main_dir.path());
6885 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6886 repo.set_head("refs/heads/main").unwrap();
6887
6888 // A linked worktree checked out on a new `feature` branch, in a
6889 // directory whose basename is deliberately *not* the repo name.
6890 let wt_parent = tempfile::tempdir().unwrap();
6891 let wt_path = wt_parent.path().join("feature-wt");
6892 add_worktree(&repo, a, &wt_path, "feature");
6893
6894 let status = git_status(&wt_path);
6895 assert!(status.is_worktree);
6896 assert_eq!(status.branch.as_deref(), Some("feature"));
6897 // The worktree names its *parent* repo, not its worktree-folder basename.
6898 assert_eq!(
6899 status.main_repo.as_deref(),
6900 main_dir.path().file_name().and_then(|n| n.to_str())
6901 );
6902
6903 // The main checkout resolves the same repo name and is not a worktree.
6904 let main_status = git_status(main_dir.path());
6905 assert!(!main_status.is_worktree);
6906 assert_eq!(main_status.main_repo, status.main_repo);
6907 }
6908
6909 #[test]
6910 fn window_label_marks_worktree_with_fork_glyph() {
6911 let main_dir = tempfile::tempdir().unwrap();
6912 let repo = init_repo(main_dir.path());
6913 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
6914 repo.set_head("refs/heads/main").unwrap();
6915 let wt_parent = tempfile::tempdir().unwrap();
6916 let wt_path = wt_parent.path().join("feature-wt");
6917 add_worktree(&repo, a, &wt_path, "feature");
6918
6919 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
6920 let entry = WindowEntry {
6921 key: "k".to_string(),
6922 folders: vec![wt_path],
6923 repo: Some("feature-wt".to_string()),
6924 title: None,
6925 pid: None,
6926 last_seen: Utc::now(),
6927 };
6928 // A worktree line: parent repo, the fork glyph, then the branch (no
6929 // upstream here, so no sync suffix).
6930 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
6931 }
6932
6933 #[test]
6934 fn main_repo_name_derives_from_common_dir() {
6935 // Normal layout: the repo is the directory that contains `.git`.
6936 assert_eq!(
6937 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
6938 Some("omni-dev")
6939 );
6940 // A trailing slash on the common dir does not change the answer.
6941 assert_eq!(
6942 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
6943 Some("omni-dev")
6944 );
6945 // A bare repo: its own directory name, without the `.git` suffix.
6946 assert_eq!(
6947 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
6948 Some("omni-dev")
6949 );
6950 // A `.git` at the filesystem root has no parent name to use.
6951 assert_eq!(main_repo_name(Path::new("/.git")), None);
6952 }
6953
6954 // --- Repo/worktree tree (#1265) ----------------------------------------
6955
6956 /// Pulls the `repos` array out of a `tree` payload (owned, so it survives a
6957 /// temporary payload).
6958 fn repos_of(payload: &Value) -> Vec<Value> {
6959 payload
6960 .get("repos")
6961 .and_then(Value::as_array)
6962 .expect("repos array")
6963 .clone()
6964 }
6965
6966 fn github(owner: &str, name: &str) -> Option<GithubIdentity> {
6967 Some(GithubIdentity {
6968 owner: owner.to_string(),
6969 name: name.to_string(),
6970 })
6971 }
6972
6973 #[test]
6974 fn github_identity_parses_supported_forms() {
6975 // https / http, with and without the `.git` suffix.
6976 assert_eq!(
6977 github_identity("https://github.com/rust-works/omni-dev.git"),
6978 github("rust-works", "omni-dev")
6979 );
6980 assert_eq!(
6981 github_identity("https://github.com/rust-works/omni-dev"),
6982 github("rust-works", "omni-dev")
6983 );
6984 assert_eq!(github_identity("http://github.com/o/r"), github("o", "r"));
6985 // SCP-like and ssh:// / git:// forms.
6986 assert_eq!(
6987 github_identity("git@github.com:rust-works/omni-dev.git"),
6988 github("rust-works", "omni-dev")
6989 );
6990 assert_eq!(
6991 github_identity("ssh://git@github.com/o/r.git"),
6992 github("o", "r")
6993 );
6994 assert_eq!(github_identity("git://github.com/o/r"), github("o", "r"));
6995 // A trailing slash and surrounding whitespace are tolerated.
6996 assert_eq!(
6997 github_identity(" https://github.com/o/r/ "),
6998 github("o", "r")
6999 );
7000 }
7001
7002 #[test]
7003 fn github_identity_rejects_non_github_and_malformed() {
7004 // Non-GitHub hosts.
7005 assert_eq!(github_identity("https://gitlab.com/o/r.git"), None);
7006 assert_eq!(github_identity("git@example.com:o/r.git"), None);
7007 // Missing or extra path segments.
7008 assert_eq!(github_identity("https://github.com/onlyowner"), None);
7009 assert_eq!(github_identity("https://github.com/o/r/extra"), None);
7010 assert_eq!(github_identity("https://github.com/"), None);
7011 // Not a URL at all.
7012 assert_eq!(github_identity("not a url"), None);
7013 }
7014
7015 #[test]
7016 fn remote_github_identity_reads_origin_then_falls_back() {
7017 let dir = tempfile::tempdir().unwrap();
7018 let repo = init_repo(dir.path());
7019 // No remotes → None.
7020 assert_eq!(remote_github_identity(&repo), None);
7021 // A non-GitHub origin is not a match.
7022 repo.remote("origin", "https://gitlab.com/o/r.git").unwrap();
7023 assert_eq!(remote_github_identity(&repo), None);
7024 // A GitHub origin resolves to its identity.
7025 repo.remote_set_url("origin", "git@github.com:rust-works/omni-dev.git")
7026 .unwrap();
7027 assert_eq!(
7028 remote_github_identity(&repo),
7029 github("rust-works", "omni-dev")
7030 );
7031
7032 // Origin non-GitHub but another remote is GitHub: the fallback loop over
7033 // the remaining remotes finds it.
7034 repo.remote_set_url("origin", "https://gitlab.com/o/r.git")
7035 .unwrap();
7036 repo.remote("upstream", "https://github.com/other/proj.git")
7037 .unwrap();
7038 assert_eq!(remote_github_identity(&repo), github("other", "proj"));
7039 }
7040
7041 #[tokio::test]
7042 async fn tree_is_empty_with_no_windows_and_skips_non_repos() {
7043 let svc = WorktreesService::new();
7044 // No windows → an empty repo set (not an error), toggle at its default.
7045 assert_eq!(
7046 svc.handle("tree", Value::Null).await.unwrap(),
7047 json!({ "repos": [], "show_closed": true })
7048 );
7049 // A plain non-repo folder is skipped rather than sinking the op.
7050 let plain = tempfile::tempdir().unwrap();
7051 svc.handle(
7052 "register",
7053 json!({ "key": "w1", "folders": [plain.path()], "repo": "plain" }),
7054 )
7055 .await
7056 .unwrap();
7057 assert!(repos_of(&svc.handle("tree", Value::Null).await.unwrap()).is_empty());
7058 }
7059
7060 #[tokio::test]
7061 async fn tree_enumerates_main_and_linked_with_open_join_and_github() {
7062 let main_dir = tempfile::tempdir().unwrap();
7063 let repo = init_repo(main_dir.path());
7064 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7065 repo.set_head("refs/heads/main").unwrap();
7066 // A GitHub origin so the repo carries an identity in the payload.
7067 repo.remote("origin", "git@github.com:rust-works/omni-dev.git")
7068 .unwrap();
7069
7070 // A linked worktree on a new `feature` branch, in a directory whose
7071 // basename is deliberately not the repo name.
7072 let wt_parent = tempfile::tempdir().unwrap();
7073 let wt_path = wt_parent.path().join("feature-wt");
7074 add_worktree(&repo, a, &wt_path, "feature");
7075
7076 let svc = WorktreesService::new();
7077 // A window open on the main checkout and one on the linked worktree —
7078 // two windows, but one repo (they must dedupe).
7079 svc.handle(
7080 "register",
7081 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
7082 )
7083 .await
7084 .unwrap();
7085 svc.handle(
7086 "register",
7087 json!({ "key": "wf", "folders": [wt_path], "repo": "feature-wt" }),
7088 )
7089 .await
7090 .unwrap();
7091
7092 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7093 assert_eq!(
7094 repos.len(),
7095 1,
7096 "two worktrees of one repo dedupe: {repos:?}"
7097 );
7098 let repo0 = &repos[0];
7099 // Repo identity is the parent-repo name (not a worktree-folder basename).
7100 assert_eq!(
7101 repo0.get("main_repo").and_then(Value::as_str),
7102 main_dir.path().file_name().and_then(|n| n.to_str())
7103 );
7104 assert_eq!(
7105 repo0.pointer("/github/owner").and_then(Value::as_str),
7106 Some("rust-works")
7107 );
7108 assert_eq!(
7109 repo0.pointer("/github/name").and_then(Value::as_str),
7110 Some("omni-dev")
7111 );
7112 assert!(repo0.get("root").and_then(Value::as_str).is_some());
7113
7114 let worktrees = repo0.get("worktrees").and_then(Value::as_array).unwrap();
7115 assert_eq!(worktrees.len(), 2);
7116 // Main working tree first: is_main, open, with the main window's key.
7117 let main_wt = &worktrees[0];
7118 assert_eq!(main_wt.get("is_main").and_then(Value::as_bool), Some(true));
7119 assert_eq!(main_wt.get("open").and_then(Value::as_bool), Some(true));
7120 assert_eq!(
7121 main_wt.get("window_key").and_then(Value::as_str),
7122 Some("wm")
7123 );
7124 assert_eq!(main_wt.get("branch").and_then(Value::as_str), Some("main"));
7125 // Linked worktree: not main, open via the feature window.
7126 let linked = &worktrees[1];
7127 assert_eq!(linked.get("is_main").and_then(Value::as_bool), Some(false));
7128 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(true));
7129 assert_eq!(linked.get("window_key").and_then(Value::as_str), Some("wf"));
7130 assert_eq!(
7131 linked.get("branch").and_then(Value::as_str),
7132 Some("feature")
7133 );
7134 }
7135
7136 #[tokio::test]
7137 async fn tree_marks_unopened_linked_worktree_closed_and_omits_github() {
7138 let main_dir = tempfile::tempdir().unwrap();
7139 let repo = init_repo(main_dir.path());
7140 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
7141 repo.set_head("refs/heads/main").unwrap();
7142 // No remote at all → the repo carries no `github` identity.
7143 let wt_parent = tempfile::tempdir().unwrap();
7144 let wt_path = wt_parent.path().join("feature-wt");
7145 add_worktree(&repo, a, &wt_path, "feature");
7146
7147 let svc = WorktreesService::new();
7148 // Only the main checkout has a window; the linked worktree has none.
7149 svc.handle(
7150 "register",
7151 json!({ "key": "wm", "folders": [main_dir.path()], "repo": "omni-dev" }),
7152 )
7153 .await
7154 .unwrap();
7155
7156 let repos = repos_of(&svc.handle("tree", Value::Null).await.unwrap());
7157 assert_eq!(repos.len(), 1);
7158 assert!(repos[0].get("github").is_none(), "no remote → no github");
7159 let worktrees = repos[0].get("worktrees").and_then(Value::as_array).unwrap();
7160 let linked = worktrees
7161 .iter()
7162 .find(|w| w.get("is_main").and_then(Value::as_bool) == Some(false))
7163 .expect("the linked worktree");
7164 // Enumerated even though no window has it open, and marked closed.
7165 assert_eq!(linked.get("open").and_then(Value::as_bool), Some(false));
7166 assert!(linked.get("window_key").is_none());
7167 }
7168
7169 // --- Close op (#1277) --------------------------------------------------
7170
7171 /// Builds a repo whose main working tree is on `trunk` with one **clean**
7172 /// linked worktree on `feature`, returning the temp dirs (kept alive so the
7173 /// paths stay valid) and the linked worktree path.
7174 fn repo_with_linked_worktree() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) {
7175 let main_dir = tempfile::tempdir().unwrap();
7176 let repo = init_repo(main_dir.path());
7177 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7178 repo.set_head("refs/heads/trunk").unwrap();
7179 let wt_parent = tempfile::tempdir().unwrap();
7180 let wt_path = wt_parent.path().join("feature-wt");
7181 add_worktree(&repo, a, &wt_path, "feature");
7182 (main_dir, wt_parent, wt_path)
7183 }
7184
7185 /// [`repo_with_linked_worktree`] with a **second** linked worktree of the same
7186 /// repo — the shape a multi-select delete fans out over, and the only one where
7187 /// two prunes share a `.git/worktrees` to race on (#1359).
7188 fn repo_with_two_linked_worktrees() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf)
7189 {
7190 let main_dir = tempfile::tempdir().unwrap();
7191 let repo = init_repo(main_dir.path());
7192 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7193 repo.set_head("refs/heads/trunk").unwrap();
7194 let wt_parent = tempfile::tempdir().unwrap();
7195 let first = wt_parent.path().join("first-wt");
7196 let second = wt_parent.path().join("second-wt");
7197 add_worktree(&repo, a, &first, "first");
7198 add_worktree(&repo, a, &second, "second");
7199 (main_dir, wt_parent, first, second)
7200 }
7201
7202 #[tokio::test]
7203 async fn close_removes_two_linked_worktrees_of_one_repo_concurrently() {
7204 let (main_dir, _wtp, first, second) = repo_with_two_linked_worktrees();
7205 let svc = Arc::new(WorktreesService::new());
7206
7207 // The multi-select fan-out: one `close` op per target, both in flight at
7208 // once against the one repo's shared admin state. Genuinely concurrent
7209 // even on this single-threaded runtime — each op's prune is a
7210 // `spawn_blocking`, so awaiting its join yields to the other op.
7211 //
7212 // This guards the fan-out end-to-end (both ops complete, neither is
7213 // starved or deadlocked by `prune_lock`); it is deliberately *not* sold as
7214 // a race detector for the lock, because it is not one — it passes with the
7215 // guard removed, the two prunes being far too quick to collide reliably.
7216 let close = |path: PathBuf| {
7217 let svc = svc.clone();
7218 async move {
7219 svc.handle(
7220 "close",
7221 json!({ "path": path, "remove": true, "confirmed": true }),
7222 )
7223 .await
7224 }
7225 };
7226 let (a, b) = tokio::join!(close(first.clone()), close(second.clone()));
7227
7228 assert_eq!(a.unwrap(), json!({ "removed": true }));
7229 assert_eq!(b.unwrap(), json!({ "removed": true }));
7230 assert!(!first.exists());
7231 assert!(!second.exists());
7232 // Both *admin* entries pruned too, not merely the directories — the half
7233 // the two ops contend on.
7234 let repo = Repository::open(main_dir.path()).unwrap();
7235 assert!(repo.worktrees().unwrap().is_empty());
7236 }
7237
7238 #[tokio::test]
7239 async fn concurrent_closes_overlap_their_heartbeat_waits() {
7240 let (_main, _wtp, first, second) = repo_with_two_linked_worktrees();
7241 let svc = Arc::new(WorktreesService::new());
7242 // Two *different* windows own the two targets — the multi-select shape.
7243 for (key, path) in [("w2", &first), ("w3", &second)] {
7244 svc.handle("register", json!({ "key": key, "folders": [path] }))
7245 .await
7246 .unwrap();
7247 }
7248
7249 let spawn_close = |path: PathBuf| {
7250 let svc = svc.clone();
7251 tokio::spawn(async move {
7252 svc.handle(
7253 "close",
7254 json!({
7255 "path": path,
7256 "remove": true,
7257 "confirmed": true,
7258 "requester_key": "w1",
7259 }),
7260 )
7261 .await
7262 })
7263 };
7264 let a = spawn_close(first.clone());
7265 let b = spawn_close(second.clone());
7266
7267 // The crux of #1359, and the one thing pinning `prune_lock`'s placement:
7268 // *both* windows are told to close while *neither* op has finished, so the
7269 // two multi-second heartbeat waits are in flight at once. Take the guard
7270 // before `await_windows_closed` instead of after and this fails — op B
7271 // would sit on the lock without ever marking w3, restoring exactly the
7272 // N-stacked-waits latency the fan-out exists to remove.
7273 for key in ["w2", "w3"] {
7274 let mut saw_close = false;
7275 for _ in 0..400 {
7276 let hb = svc
7277 .handle("heartbeat", json!({ "key": key }))
7278 .await
7279 .unwrap();
7280 if hb.get("close").and_then(Value::as_bool) == Some(true) {
7281 saw_close = true;
7282 break;
7283 }
7284 tokio::time::sleep(Duration::from_millis(5)).await;
7285 }
7286 assert!(saw_close, "{key} should have been told to close while the other target's close was still waiting");
7287 }
7288 assert!(
7289 !a.is_finished() && !b.is_finished(),
7290 "neither close can have finished: both windows are still registered"
7291 );
7292
7293 // Both windows close; both ops then remove.
7294 for key in ["w2", "w3"] {
7295 svc.handle("unregister", json!({ "key": key }))
7296 .await
7297 .unwrap();
7298 }
7299 assert_eq!(a.await.unwrap().unwrap(), json!({ "removed": true }));
7300 assert_eq!(b.await.unwrap().unwrap(), json!({ "removed": true }));
7301 assert!(!first.exists());
7302 assert!(!second.exists());
7303 }
7304
7305 #[tokio::test]
7306 async fn close_safety_check_reports_clean_linked_as_removable_with_no_risks() {
7307 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7308 let svc = WorktreesService::new();
7309 // Phase 1 (confirmed absent) on a clean linked worktree: removable, not
7310 // main, no risks → the extension proceeds with no dialog.
7311 let report = svc
7312 .handle("close", json!({ "path": wt_path, "remove": true }))
7313 .await
7314 .unwrap();
7315 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7316 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
7317 assert_eq!(report.get("open").and_then(Value::as_bool), Some(false));
7318 assert!(report
7319 .get("risks")
7320 .and_then(Value::as_array)
7321 .unwrap()
7322 .is_empty());
7323 // No side effects: the worktree still exists.
7324 assert!(wt_path.exists());
7325 }
7326
7327 #[tokio::test]
7328 async fn close_removes_a_clean_linked_worktree() {
7329 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7330 let svc = WorktreesService::new();
7331 let reply = svc
7332 .handle(
7333 "close",
7334 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7335 )
7336 .await
7337 .unwrap();
7338 assert_eq!(reply, json!({ "removed": true }));
7339 assert!(
7340 !wt_path.exists(),
7341 "the worktree directory should be deleted"
7342 );
7343 }
7344
7345 // --- Close-op audit logging (#1364) ------------------------------------
7346
7347 /// Thread-scoped log buffer for asserting on the `close` op's audit lines.
7348 /// Mirrors the WARN capture in `claude_cli.rs`: a shared buffer installed via
7349 /// `with_default`, so it never disturbs a global subscriber other tests set.
7350 /// The audit-line tests drive the sync helpers directly (no runtime, no
7351 /// `spawn_blocking`), so the captured events fire on this thread where the
7352 /// subscriber lives — a `tracing` event emitted after a heavy `spawn_blocking`
7353 /// under the parallel suite is *not* reliably captured this way.
7354 #[derive(Clone, Default)]
7355 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
7356
7357 impl std::io::Write for CaptureWriter {
7358 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
7359 self.0.lock().unwrap().extend_from_slice(buf);
7360 Ok(buf.len())
7361 }
7362 fn flush(&mut self) -> std::io::Result<()> {
7363 Ok(())
7364 }
7365 }
7366
7367 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
7368 type Writer = Self;
7369 fn make_writer(&'a self) -> Self::Writer {
7370 self.clone()
7371 }
7372 }
7373
7374 /// Runs `f` under a thread-local INFO-level subscriber and returns everything
7375 /// it logged. `f` must be fully synchronous on this thread.
7376 fn capture_info(f: impl FnOnce()) -> String {
7377 let writer = CaptureWriter::default();
7378 let subscriber = tracing_subscriber::fmt()
7379 .with_max_level(tracing::Level::INFO)
7380 .with_ansi(false)
7381 .with_writer(writer.clone())
7382 .finish();
7383 tracing::subscriber::with_default(subscriber, f);
7384 let logs = String::from_utf8_lossy(&writer.0.lock().unwrap()).into_owned();
7385 logs
7386 }
7387
7388 #[test]
7389 fn note_kinds_joins_slugs_and_maps_empty_to_a_dash() {
7390 assert_eq!(note_kinds(&[]), "-");
7391 assert_eq!(
7392 note_kinds(&[Note::new("dirty", "x"), Note::new("untracked", "y")]),
7393 "dirty,untracked"
7394 );
7395 }
7396
7397 #[test]
7398 fn is_self_close_true_only_when_requester_owns_an_open_window() {
7399 let windows = vec![("w1".to_string(), 1usize), ("w2".to_string(), 2)];
7400 assert!(is_self_close(Some("w1"), &windows));
7401 assert!(
7402 !is_self_close(Some("w3"), &windows),
7403 "requester owns no window"
7404 );
7405 assert!(!is_self_close(None, &windows), "no requester");
7406 assert!(!is_self_close(Some("w1"), &[]), "no open windows");
7407 }
7408
7409 #[test]
7410 fn log_and_map_removal_logs_and_maps_a_successful_prune() {
7411 let logs = capture_info(|| {
7412 let reply = log_and_map_removal(Path::new("/wt/feature"), Ok(())).unwrap();
7413 assert_eq!(reply, json!({ "removed": true }));
7414 });
7415 assert!(
7416 logs.contains("worktrees close: linked worktree pruned"),
7417 "a successful prune must log an INFO audit line, got: {logs}"
7418 );
7419 assert!(
7420 logs.contains("/wt/feature"),
7421 "the target path must ride the line, got: {logs}"
7422 );
7423 }
7424
7425 #[test]
7426 fn log_close_error_logs_at_error_and_returns_the_error_unchanged() {
7427 // ERROR is more severe than the INFO cap, so `capture_info` records it.
7428 let logs = capture_info(|| {
7429 let err = log_close_error(
7430 Path::new("/wt/feature"),
7431 "safety check",
7432 anyhow!("not a git worktree"),
7433 );
7434 assert_eq!(
7435 err.to_string(),
7436 "not a git worktree",
7437 "err propagates unchanged"
7438 );
7439 });
7440 assert!(
7441 logs.contains("worktrees close: safety check failed"),
7442 "a failed phase must log an ERROR audit line, got: {logs}"
7443 );
7444 assert!(
7445 logs.contains("not a git worktree"),
7446 "the cause must ride the line, got: {logs}"
7447 );
7448 assert!(
7449 logs.contains("/wt/feature"),
7450 "the target path must ride the line, got: {logs}"
7451 );
7452 }
7453
7454 #[test]
7455 fn log_and_map_removal_warns_and_propagates_a_prune_failure() {
7456 let logs = capture_info(|| {
7457 let err = log_and_map_removal(Path::new("/wt/feature"), Err(anyhow!("locked")));
7458 assert!(err.is_err(), "a prune failure must propagate");
7459 });
7460 assert!(
7461 logs.contains("worktrees close: worktree prune failed"),
7462 "a prune failure must log a WARN audit line, got: {logs}"
7463 );
7464 assert!(
7465 logs.contains("locked"),
7466 "the failure cause must ride the line, got: {logs}"
7467 );
7468 }
7469
7470 #[test]
7471 fn log_safety_check_logs_the_verdict_and_owning_window_key() {
7472 let git = GitSafety {
7473 is_main: false,
7474 removable: true,
7475 risks: vec![Note::new("dirty", "x"), Note::new("untracked", "y")],
7476 info: vec![],
7477 };
7478 let logs = capture_info(|| {
7479 log_safety_check(Path::new("/wt/feature"), Some("win-42"), &git, true);
7480 });
7481 assert!(
7482 logs.contains("worktrees close: safety check"),
7483 "phase-1 must log a safety-check line, got: {logs}"
7484 );
7485 assert!(
7486 logs.contains("/wt/feature"),
7487 "the path must ride the line, got: {logs}"
7488 );
7489 assert!(
7490 logs.contains("window_key=\"win-42\""),
7491 "the owning window key must ride the line, got: {logs}"
7492 );
7493 assert!(logs.contains("removable=true"), "got: {logs}");
7494 assert!(logs.contains("is_main=false"), "got: {logs}");
7495 assert!(logs.contains("open=true"), "got: {logs}");
7496 assert!(
7497 logs.contains("risks=dirty,untracked"),
7498 "the blocking risk kinds must ride the line, got: {logs}"
7499 );
7500 }
7501
7502 #[test]
7503 fn log_safety_check_renders_a_dash_when_no_window_owns_the_target() {
7504 let git = GitSafety {
7505 is_main: false,
7506 removable: true,
7507 risks: vec![],
7508 info: vec![],
7509 };
7510 let logs = capture_info(|| {
7511 log_safety_check(Path::new("/wt/feature"), None, &git, false);
7512 });
7513 assert!(
7514 logs.contains("window_key=\"-\""),
7515 "no owning window → dash, got: {logs}"
7516 );
7517 assert!(logs.contains("risks=-"), "no risks → dash, got: {logs}");
7518 }
7519
7520 #[test]
7521 fn log_executing_logs_the_routing_decision() {
7522 let logs = capture_info(|| {
7523 log_executing(Path::new("/wt/feature"), Some("win-7"), true, false, 3);
7524 });
7525 assert!(
7526 logs.contains("worktrees close: executing"),
7527 "phase-2 must log the execute routing, got: {logs}"
7528 );
7529 assert!(
7530 logs.contains("requester=\"win-7\""),
7531 "the requester key must ride the line, got: {logs}"
7532 );
7533 assert!(logs.contains("remove=true"), "got: {logs}");
7534 assert!(logs.contains("self_close=false"), "got: {logs}");
7535 assert!(logs.contains("cross_window=3"), "got: {logs}");
7536 }
7537
7538 #[test]
7539 fn log_close_abort_warns_that_a_signalled_window_did_not_close() {
7540 let logs = capture_info(|| {
7541 log_close_abort(
7542 Path::new("/wt/feature"),
7543 &anyhow!("window(s) did not close in time: win-9"),
7544 );
7545 });
7546 assert!(
7547 logs.contains("worktrees close: aborted"),
7548 "an abort must log a WARN audit line, got: {logs}"
7549 );
7550 assert!(
7551 logs.contains("/wt/feature"),
7552 "the path must ride the line, got: {logs}"
7553 );
7554 assert!(
7555 logs.contains("win-9"),
7556 "the still-open window must ride the line, got: {logs}"
7557 );
7558 }
7559
7560 #[test]
7561 fn log_window_closed_logs_the_no_removal_outcome() {
7562 let logs = capture_info(|| {
7563 log_window_closed(Path::new("/wt/feature"));
7564 });
7565 assert!(
7566 logs.contains("worktrees close: window closed, no removal"),
7567 "a remove:false close must log the no-removal outcome, got: {logs}"
7568 );
7569 assert!(
7570 logs.contains("/wt/feature"),
7571 "the path must ride the line, got: {logs}"
7572 );
7573 }
7574
7575 #[test]
7576 fn remove_worktree_deletes_the_directory_and_prunes_the_admin_metadata() {
7577 // The reorder (#1315) must still fully remove a worktree: both the
7578 // checked-out directory *and* the admin metadata git tracks it by, so it
7579 // no longer appears in `Repository::worktrees()`.
7580 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7581 let admin = main.path().join(".git").join("worktrees").join("feature");
7582 assert!(admin.exists(), "admin metadata should exist before removal");
7583
7584 remove_worktree(&wt_path).unwrap();
7585
7586 assert!(!wt_path.exists(), "the working directory should be gone");
7587 assert!(!admin.exists(), "the admin metadata should be pruned");
7588 let main_repo = Repository::open(main.path()).unwrap();
7589 assert_eq!(
7590 main_repo.worktrees().unwrap().len(),
7591 0,
7592 "git should no longer track the worktree"
7593 );
7594 }
7595
7596 #[test]
7597 fn remove_worktree_recovers_a_half_removed_orphan() {
7598 // The exact #1315 leftover: the old ordering deleted the admin metadata
7599 // first, then failed to rmdir the working tree, orphaning the directory
7600 // with a dangling `.git` gitlink. `remove_worktree` must clean it up
7601 // rather than error with "not a git worktree".
7602 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7603 let admin = main.path().join(".git").join("worktrees").join("feature");
7604 // Simulate the half-removed state: admin gone, directory (+gitlink) left.
7605 std::fs::remove_dir_all(&admin).unwrap();
7606 assert!(wt_path.join(".git").is_file(), "dangling gitlink remains");
7607 assert!(
7608 Repository::open(&wt_path).is_err(),
7609 "the orphan should not open as a repo"
7610 );
7611
7612 remove_worktree(&wt_path).unwrap();
7613 assert!(
7614 !wt_path.exists(),
7615 "the leftover directory should be removed"
7616 );
7617 }
7618
7619 #[test]
7620 fn is_orphaned_worktree_only_matches_a_dangling_linked_gitlink() {
7621 let (main, _wtp, wt_path) = repo_with_linked_worktree();
7622 // A live worktree: gitlink resolves → not an orphan.
7623 assert!(!is_orphaned_worktree(&wt_path));
7624 // The main checkout has a `.git` directory → not an orphan.
7625 assert!(!is_orphaned_worktree(main.path()));
7626 // Drop the admin metadata → the gitlink now dangles → orphan.
7627 std::fs::remove_dir_all(main.path().join(".git").join("worktrees").join("feature"))
7628 .unwrap();
7629 assert!(is_orphaned_worktree(&wt_path));
7630 }
7631
7632 #[test]
7633 fn remove_dir_all_retrying_is_idempotent_on_a_missing_directory() {
7634 let tmp = tempfile::tempdir().unwrap();
7635 let missing = tmp.path().join("gone");
7636 assert!(remove_dir_all_retrying(&missing).is_ok());
7637 }
7638
7639 #[test]
7640 fn is_transient_rmdir_error_matches_only_the_repopulated_directory_race() {
7641 use std::io::Error;
7642 for errno in [nix::libc::ENOTEMPTY, nix::libc::EEXIST, nix::libc::EBUSY] {
7643 assert!(
7644 is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
7645 "errno {errno} is the concurrent-writer race and must be retried"
7646 );
7647 }
7648 // A hard failure must surface immediately rather than burn the backoff
7649 // waiting for a condition that will never clear.
7650 for errno in [
7651 nix::libc::EACCES,
7652 nix::libc::EPERM,
7653 nix::libc::EROFS,
7654 nix::libc::ENOTDIR,
7655 ] {
7656 assert!(
7657 !is_transient_rmdir_error(&Error::from_raw_os_error(errno)),
7658 "errno {errno} is permanent and must not be retried"
7659 );
7660 }
7661 // Not from the OS at all, so there is no errno to classify.
7662 assert!(!is_transient_rmdir_error(&Error::other("synthetic")));
7663 }
7664
7665 #[test]
7666 fn remove_dir_all_retrying_surfaces_a_non_transient_error_without_retrying() {
7667 // Removing a *file* as if it were a directory fails with ENOTDIR: not the
7668 // race, so it must fail on the first attempt with the original cause
7669 // attached, leaving the path untouched.
7670 let tmp = tempfile::tempdir().unwrap();
7671 let file = tmp.path().join("not-a-directory");
7672 std::fs::write(&file, b"x").unwrap();
7673
7674 let mut attempts = 0;
7675 let err = remove_dir_all_retrying_with(&file, WORKTREE_RMDIR_BACKOFF, || {
7676 attempts += 1;
7677 std::fs::remove_dir_all(&file)
7678 })
7679 .unwrap_err();
7680
7681 assert_eq!(attempts, 1, "a permanent error must not be retried");
7682 assert!(
7683 err.to_string()
7684 .contains("failed to remove worktree directory"),
7685 "unexpected error: {err:#}"
7686 );
7687 assert!(err.source().is_some(), "the io::Error cause is preserved");
7688 assert!(file.exists());
7689 }
7690
7691 #[test]
7692 fn remove_dir_all_retrying_gives_up_after_the_backoff_is_exhausted() {
7693 // A writer that never quiesces: every sweep re-finds the directory
7694 // populated. Once the schedule runs out the ENOTEMPTY must surface rather
7695 // than the loop spinning forever.
7696 let tmp = tempfile::tempdir().unwrap();
7697 let mut attempts = 0;
7698 let backoff = [Duration::ZERO, Duration::ZERO];
7699 let err = remove_dir_all_retrying_with(tmp.path(), &backoff, || {
7700 attempts += 1;
7701 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
7702 })
7703 .unwrap_err();
7704
7705 // One attempt per delay, plus the initial one.
7706 assert_eq!(attempts, backoff.len() + 1);
7707 assert!(
7708 err.to_string()
7709 .contains("failed to remove worktree directory"),
7710 "unexpected error: {err:#}"
7711 );
7712 }
7713
7714 #[test]
7715 fn remove_dir_all_retrying_succeeds_once_the_writer_quiesces() {
7716 // The #1315 happy path, deterministically: the race clears partway through
7717 // the schedule and the removal then succeeds.
7718 let tmp = tempfile::tempdir().unwrap();
7719 let mut attempts = 0;
7720 let result = remove_dir_all_retrying_with(tmp.path(), WORKTREE_RMDIR_BACKOFF, || {
7721 attempts += 1;
7722 if attempts < 3 {
7723 Err(std::io::Error::from_raw_os_error(nix::libc::ENOTEMPTY))
7724 } else {
7725 Ok(())
7726 }
7727 });
7728 assert!(result.is_ok(), "{result:?}");
7729 assert_eq!(attempts, 3);
7730 }
7731
7732 #[test]
7733 fn is_orphaned_worktree_ignores_a_git_file_that_is_not_a_gitlink() {
7734 // A `.git` file that is readable but carries no `gitdir:` pointer is not
7735 // something we may delete.
7736 let tmp = tempfile::tempdir().unwrap();
7737 std::fs::write(tmp.path().join(".git"), b"not a gitlink\n").unwrap();
7738 assert!(!is_orphaned_worktree(tmp.path()));
7739 }
7740
7741 #[test]
7742 fn remove_worktree_rejects_a_path_that_is_not_a_worktree() {
7743 // Neither a repo nor an orphan: refuse it rather than recursively deleting
7744 // whatever directory was passed in.
7745 let tmp = tempfile::tempdir().unwrap();
7746 let plain = tmp.path().join("plain");
7747 std::fs::create_dir(&plain).unwrap();
7748
7749 let err = remove_worktree(&plain).unwrap_err();
7750
7751 assert!(
7752 err.to_string().contains("not a git worktree"),
7753 "unexpected error: {err:#}"
7754 );
7755 assert!(plain.exists(), "a non-worktree path must be left alone");
7756 }
7757
7758 #[test]
7759 fn remove_worktree_succeeds_while_a_concurrent_writer_winds_down() {
7760 // Acceptance criterion (#1315): a language server / cargo still writing
7761 // into `target/` as the window closes makes the recursive rmdir race with
7762 // "Directory not empty". A background thread reproduces that by
7763 // repopulating `target/` for a bounded window; removal must retry past it
7764 // and still succeed once the writer stops.
7765 use std::sync::atomic::{AtomicBool, Ordering};
7766 use std::sync::Arc;
7767
7768 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7769 let target = wt_path.join("target");
7770 std::fs::create_dir_all(&target).unwrap();
7771
7772 let stop = Arc::new(AtomicBool::new(false));
7773 let writer_stop = Arc::clone(&stop);
7774 let writer_dir = target;
7775 let writer = std::thread::spawn(move || {
7776 let mut n = 0u64;
7777 // Churn hard for ~400ms (well under the ~2.75s retry budget), then
7778 // stop so a later removal pass finds the directory quiescent.
7779 let deadline = std::time::Instant::now() + Duration::from_millis(400);
7780 while !writer_stop.load(Ordering::Relaxed) && std::time::Instant::now() < deadline {
7781 let nested = writer_dir.join("nested");
7782 // Best-effort: the parent may be mid-deletion — ignore failures.
7783 let _ = std::fs::create_dir_all(&nested);
7784 let _ = std::fs::write(nested.join(format!("artifact-{n}.tmp")), b"x");
7785 n += 1;
7786 }
7787 });
7788
7789 let result = remove_worktree(&wt_path);
7790 stop.store(true, Ordering::Relaxed);
7791 writer.join().unwrap();
7792
7793 assert!(
7794 result.is_ok(),
7795 "removal should retry past the writer: {result:?}"
7796 );
7797 assert!(!wt_path.exists(), "the worktree directory should be gone");
7798 }
7799
7800 #[tokio::test]
7801 async fn close_safety_check_flags_untracked_and_does_not_remove_without_confirmation() {
7802 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7803 // An untracked file in the worktree would be lost on removal.
7804 std::fs::write(wt_path.join("scratch.txt"), b"work in progress").unwrap();
7805
7806 let svc = WorktreesService::new();
7807 let report = svc
7808 .handle("close", json!({ "path": wt_path, "remove": true }))
7809 .await
7810 .unwrap();
7811 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7812 assert!(
7813 risks
7814 .iter()
7815 .any(|r| r.get("kind").and_then(Value::as_str) == Some("untracked")),
7816 "expected an untracked risk: {report}"
7817 );
7818 // Still removable — the risk only means "confirm first", not "refuse".
7819 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
7820 // The unconfirmed check has no side effects.
7821 assert!(wt_path.exists());
7822 }
7823
7824 #[tokio::test]
7825 async fn close_confirmed_removes_a_dirty_worktree() {
7826 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7827 std::fs::write(wt_path.join("scratch.txt"), b"discard me").unwrap();
7828 let svc = WorktreesService::new();
7829 // With confirmation, the risks are overridden and removal proceeds.
7830 let reply = svc
7831 .handle(
7832 "close",
7833 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7834 )
7835 .await
7836 .unwrap();
7837 assert_eq!(reply, json!({ "removed": true }));
7838 assert!(!wt_path.exists());
7839 }
7840
7841 #[tokio::test]
7842 async fn close_refuses_to_remove_the_main_working_tree() {
7843 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
7844 let svc = WorktreesService::new();
7845 // Phase 1: the main tree reports not-removable, marked main.
7846 let report = svc
7847 .handle("close", json!({ "path": main.path(), "remove": true }))
7848 .await
7849 .unwrap();
7850 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(true));
7851 assert_eq!(
7852 report.get("removable").and_then(Value::as_bool),
7853 Some(false)
7854 );
7855 // Phase 2: even a confirmed delete of the main tree is refused
7856 // defensively, and the directory is untouched.
7857 assert!(svc
7858 .handle(
7859 "close",
7860 json!({ "path": main.path(), "remove": true, "confirmed": true }),
7861 )
7862 .await
7863 .is_err());
7864 assert!(main.path().exists());
7865 }
7866
7867 #[tokio::test]
7868 async fn close_removes_a_linked_worktree_on_the_default_branch_and_keeps_the_branch() {
7869 // The case a naive impl would wrongly protect: a linked worktree checked
7870 // out on `main` (the default branch) is *still a linked worktree*, so it
7871 // is fully deletable — and `main` survives (removal never deletes a branch).
7872 let main_dir = tempfile::tempdir().unwrap();
7873 let repo = init_repo(main_dir.path());
7874 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
7875 repo.set_head("refs/heads/trunk").unwrap();
7876 let wt_parent = tempfile::tempdir().unwrap();
7877 let wt_path = wt_parent.path().join("main-wt");
7878 add_worktree(&repo, a, &wt_path, "main");
7879
7880 let svc = WorktreesService::new();
7881 let reply = svc
7882 .handle(
7883 "close",
7884 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7885 )
7886 .await
7887 .unwrap();
7888 assert_eq!(reply, json!({ "removed": true }));
7889 assert!(!wt_path.exists());
7890 // The `main` branch is untouched by the worktree removal.
7891 assert!(
7892 repo.find_branch("main", git2::BranchType::Local).is_ok(),
7893 "the default branch must survive worktree removal"
7894 );
7895 }
7896
7897 #[tokio::test]
7898 async fn close_is_idempotent_when_the_worktree_is_already_gone() {
7899 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7900 let svc = WorktreesService::new();
7901 // First removal succeeds.
7902 svc.handle(
7903 "close",
7904 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7905 )
7906 .await
7907 .unwrap();
7908 // A second confirmed close of the now-missing path is a clean success,
7909 // not an error (a stale snapshot must not crash).
7910 let reply = svc
7911 .handle(
7912 "close",
7913 json!({ "path": wt_path, "remove": true, "confirmed": true }),
7914 )
7915 .await
7916 .unwrap();
7917 assert_eq!(reply, json!({ "removed": true }));
7918 }
7919
7920 #[tokio::test]
7921 async fn close_safety_check_detects_detached_head_unreachable_commits() {
7922 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7923 // In the worktree, commit onto a detached HEAD so the new commit is
7924 // reachable from no ref — it would be GC'd on removal.
7925 let wt_repo = Repository::open(&wt_path).unwrap();
7926 let parent_oid = wt_repo.head().unwrap().target().unwrap();
7927 let parent = wt_repo.find_commit(parent_oid).unwrap();
7928 let orphan = empty_commit(&wt_repo, None, &[&parent], "orphan");
7929 wt_repo.set_head_detached(orphan).unwrap();
7930
7931 let svc = WorktreesService::new();
7932 let report = svc
7933 .handle("close", json!({ "path": wt_path, "remove": true }))
7934 .await
7935 .unwrap();
7936 let risks = report.get("risks").and_then(Value::as_array).unwrap();
7937 assert!(
7938 risks
7939 .iter()
7940 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
7941 "expected an unreachable-commits risk: {report}"
7942 );
7943 }
7944
7945 #[tokio::test]
7946 async fn close_self_close_removes_when_the_requester_owns_the_target() {
7947 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7948 let svc = WorktreesService::new();
7949 // The requesting window itself has the worktree open: it is the only
7950 // owning window, so there is nothing to wait on — remove and reply, and
7951 // the extension closes its own window on `ok`.
7952 svc.handle(
7953 "register",
7954 json!({ "key": "w1", "folders": [wt_path], "repo": "feature-wt" }),
7955 )
7956 .await
7957 .unwrap();
7958 let reply = svc
7959 .handle(
7960 "close",
7961 json!({
7962 "path": wt_path,
7963 "remove": true,
7964 "confirmed": true,
7965 "requester_key": "w1",
7966 }),
7967 )
7968 .await
7969 .unwrap();
7970 assert_eq!(reply, json!({ "removed": true }));
7971 assert!(!wt_path.exists());
7972 }
7973
7974 #[tokio::test]
7975 async fn close_safety_check_surfaces_the_owning_window() {
7976 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
7977 let svc = WorktreesService::new();
7978 // A multi-root window owns the target: the report surfaces its key and
7979 // folder count so the extension can warn "all N folders will close".
7980 svc.handle(
7981 "register",
7982 json!({ "key": "w2", "folders": [&wt_path, "/tmp/other"], "repo": "feature-wt" }),
7983 )
7984 .await
7985 .unwrap();
7986 let report = svc
7987 .handle("close", json!({ "path": wt_path, "remove": true }))
7988 .await
7989 .unwrap();
7990 assert_eq!(report.get("open").and_then(Value::as_bool), Some(true));
7991 assert_eq!(report.get("window_key").and_then(Value::as_str), Some("w2"));
7992 assert_eq!(
7993 report.get("window_folder_count").and_then(Value::as_u64),
7994 Some(2)
7995 );
7996 }
7997
7998 #[tokio::test]
7999 async fn heartbeat_op_surfaces_a_pending_close_directive_once() {
8000 let svc = WorktreesService::new();
8001 svc.handle("register", register_payload("w1", Some("r"), "/tmp/a"))
8002 .await
8003 .unwrap();
8004 // No directive → a plain `{ known: true }`, byte-identical to before.
8005 assert_eq!(
8006 svc.handle("heartbeat", json!({ "key": "w1" }))
8007 .await
8008 .unwrap(),
8009 json!({ "known": true })
8010 );
8011 // Marked → the next heartbeat carries `close: true`, exactly once.
8012 svc.registry.mark_close_pending("w1");
8013 assert_eq!(
8014 svc.handle("heartbeat", json!({ "key": "w1" }))
8015 .await
8016 .unwrap(),
8017 json!({ "known": true, "close": true })
8018 );
8019 assert_eq!(
8020 svc.handle("heartbeat", json!({ "key": "w1" }))
8021 .await
8022 .unwrap(),
8023 json!({ "known": true })
8024 );
8025 }
8026
8027 #[tokio::test]
8028 async fn close_signals_a_cross_window_target_then_removes_after_it_closes() {
8029 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
8030 let svc = Arc::new(WorktreesService::new());
8031 // A *different* window (not the requester) owns the target.
8032 svc.handle(
8033 "register",
8034 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
8035 )
8036 .await
8037 .unwrap();
8038
8039 // Drive the destructive close concurrently: it marks w2 to close and
8040 // waits for it to unregister before removing.
8041 let svc2 = svc.clone();
8042 let path = wt_path.clone();
8043 let close = tokio::spawn(async move {
8044 svc2.handle(
8045 "close",
8046 json!({
8047 "path": path,
8048 "remove": true,
8049 "confirmed": true,
8050 "requester_key": "w1",
8051 }),
8052 )
8053 .await
8054 });
8055
8056 // Simulate w2's extension: its next heartbeat sees `close: true`, so it
8057 // closes its window and unregisters. Poll until the directive appears.
8058 let mut saw_close = false;
8059 for _ in 0..200 {
8060 let hb = svc
8061 .handle("heartbeat", json!({ "key": "w2" }))
8062 .await
8063 .unwrap();
8064 if hb.get("close").and_then(Value::as_bool) == Some(true) {
8065 saw_close = true;
8066 svc.handle("unregister", json!({ "key": "w2" }))
8067 .await
8068 .unwrap();
8069 break;
8070 }
8071 tokio::time::sleep(Duration::from_millis(5)).await;
8072 }
8073 assert!(saw_close, "w2 should have been told to close");
8074
8075 // Once w2 has unregistered, the close op removes the worktree.
8076 let reply = close.await.unwrap().unwrap();
8077 assert_eq!(reply, json!({ "removed": true }));
8078 assert!(!wt_path.exists());
8079 }
8080
8081 #[tokio::test]
8082 async fn await_windows_closed_times_out_when_a_window_never_closes() {
8083 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
8084 let svc = WorktreesService::new();
8085 svc.handle(
8086 "register",
8087 json!({ "key": "w2", "folders": [&wt_path], "repo": "feature-wt" }),
8088 )
8089 .await
8090 .unwrap();
8091 // The owning window never unregisters: the wait gives up (with a short
8092 // timeout here) rather than block, and names the still-open window.
8093 let err = await_windows_closed(
8094 &svc.registry,
8095 &wt_path,
8096 Some("w1"),
8097 Duration::from_millis(150),
8098 Duration::from_millis(25),
8099 )
8100 .await
8101 .unwrap_err();
8102 assert!(
8103 err.to_string().contains("w2"),
8104 "error names the window: {err}"
8105 );
8106 // The requester itself is excluded, so a self-only owner returns at once.
8107 await_windows_closed(
8108 &svc.registry,
8109 &wt_path,
8110 Some("w2"),
8111 Duration::from_millis(150),
8112 Duration::from_millis(25),
8113 )
8114 .await
8115 .unwrap();
8116 }
8117
8118 #[tokio::test]
8119 async fn close_window_without_remove_replies_closed_and_never_deletes() {
8120 let (main, _wtp, _wt_path) = repo_with_linked_worktree();
8121 let svc = WorktreesService::new();
8122 // "Close Window" on the main tree: no git inspection, no removal.
8123 let reply = svc
8124 .handle("close", json!({ "path": main.path(), "remove": false }))
8125 .await
8126 .unwrap();
8127 assert_eq!(reply, json!({ "closed": true }));
8128 assert!(main.path().exists());
8129 }
8130
8131 #[tokio::test]
8132 async fn close_safety_check_flags_modified_tracked_files() {
8133 // A tracked file, checked out into the linked worktree, then modified —
8134 // its content is lost on removal, so it is a `dirty` risk (distinct from
8135 // the untracked case).
8136 let main_dir = tempfile::tempdir().unwrap();
8137 let repo = init_repo(main_dir.path());
8138 let a = commit_file(&repo, "refs/heads/trunk", "tracked.txt", b"original\n", "A");
8139 repo.set_head("refs/heads/trunk").unwrap();
8140 let wt_parent = tempfile::tempdir().unwrap();
8141 let wt_path = wt_parent.path().join("feature-wt");
8142 add_worktree(&repo, a, &wt_path, "feature");
8143 std::fs::write(wt_path.join("tracked.txt"), b"uncommitted change\n").unwrap();
8144
8145 let svc = WorktreesService::new();
8146 let report = svc
8147 .handle("close", json!({ "path": wt_path, "remove": true }))
8148 .await
8149 .unwrap();
8150 let risks = report.get("risks").and_then(Value::as_array).unwrap();
8151 assert!(
8152 risks
8153 .iter()
8154 .any(|r| r.get("kind").and_then(Value::as_str) == Some("dirty")),
8155 "expected a dirty risk: {report}"
8156 );
8157 }
8158
8159 #[tokio::test]
8160 async fn close_safety_check_flags_an_in_progress_operation() {
8161 // Plant a MERGE_HEAD in the worktree's gitdir so `repo.state()` reports a
8162 // non-Clean (interrupted merge) state — its progress is lost on removal.
8163 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
8164 let wt_repo = Repository::open(&wt_path).unwrap();
8165 let head = wt_repo.head().unwrap().target().unwrap();
8166 std::fs::write(wt_repo.path().join("MERGE_HEAD"), format!("{head}\n")).unwrap();
8167 assert_ne!(wt_repo.state(), RepositoryState::Clean);
8168
8169 let svc = WorktreesService::new();
8170 let report = svc
8171 .handle("close", json!({ "path": wt_path, "remove": true }))
8172 .await
8173 .unwrap();
8174 let risks = report.get("risks").and_then(Value::as_array).unwrap();
8175 assert!(
8176 risks
8177 .iter()
8178 .any(|r| r.get("kind").and_then(Value::as_str) == Some("in-progress")),
8179 "expected an in-progress risk: {report}"
8180 );
8181 }
8182
8183 #[tokio::test]
8184 async fn close_safety_check_reports_unpushed_commits_as_info_not_a_risk() {
8185 // A linked worktree on `feature`, which tracks `origin/feature` and is one
8186 // commit ahead. The unpushed commit is INFO (the branch — and thus the
8187 // commit — survives removal), never a blocking risk.
8188 let main_dir = tempfile::tempdir().unwrap();
8189 let repo = init_repo(main_dir.path());
8190 let a = empty_commit(&repo, Some("refs/heads/trunk"), &[], "A");
8191 repo.set_head("refs/heads/trunk").unwrap();
8192 let a_commit = repo.find_commit(a).unwrap();
8193 repo.branch("feature", &a_commit, false).unwrap();
8194 repo.reference("refs/remotes/origin/feature", a, true, "origin feature")
8195 .unwrap();
8196 // `feature` advances one commit past `origin/feature`.
8197 empty_commit(&repo, Some("refs/heads/feature"), &[&a_commit], "B");
8198 drop(a_commit);
8199 let mut cfg = repo.config().unwrap();
8200 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
8201 .unwrap();
8202 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
8203 .unwrap();
8204 cfg.set_str("branch.feature.remote", "origin").unwrap();
8205 cfg.set_str("branch.feature.merge", "refs/heads/feature")
8206 .unwrap();
8207 // A worktree on the existing `feature` branch (not created fresh, so it
8208 // keeps the ahead-of-upstream divergence).
8209 let wt_parent = tempfile::tempdir().unwrap();
8210 let wt_path = wt_parent.path().join("feature-wt");
8211 let reference = repo.find_reference("refs/heads/feature").unwrap();
8212 let mut opts = git2::WorktreeAddOptions::new();
8213 opts.reference(Some(&reference));
8214 repo.worktree("feature", &wt_path, Some(&opts)).unwrap();
8215
8216 let svc = WorktreesService::new();
8217 let report = svc
8218 .handle("close", json!({ "path": wt_path, "remove": true }))
8219 .await
8220 .unwrap();
8221 // Unpushed commits appear as `info`, and the worktree is still cleanly
8222 // removable with no blocking risks.
8223 let info = report.get("info").and_then(Value::as_array).unwrap();
8224 assert!(
8225 info.iter()
8226 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unpushed")),
8227 "expected an unpushed info note: {report}"
8228 );
8229 assert!(
8230 report
8231 .get("risks")
8232 .and_then(Value::as_array)
8233 .unwrap()
8234 .is_empty(),
8235 "unpushed commits alone must not block: {report}"
8236 );
8237 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
8238 }
8239
8240 #[tokio::test]
8241 async fn close_safety_check_ignores_gitignored_files() {
8242 // With `.gitignore` committed, an ignored artifact is the only worktree
8243 // change — it must not count as untracked (it is regenerable), so the
8244 // worktree stays cleanly removable with no risks.
8245 let main_dir = tempfile::tempdir().unwrap();
8246 let repo = init_repo(main_dir.path());
8247 let a = commit_file(&repo, "refs/heads/trunk", ".gitignore", b"build/\n", "A");
8248 repo.set_head("refs/heads/trunk").unwrap();
8249 let wt_parent = tempfile::tempdir().unwrap();
8250 let wt_path = wt_parent.path().join("feature-wt");
8251 add_worktree(&repo, a, &wt_path, "feature");
8252 std::fs::create_dir(wt_path.join("build")).unwrap();
8253 std::fs::write(wt_path.join("build/artifact.o"), b"junk").unwrap();
8254
8255 let svc = WorktreesService::new();
8256 let report = svc
8257 .handle("close", json!({ "path": wt_path, "remove": true }))
8258 .await
8259 .unwrap();
8260 assert!(
8261 report
8262 .get("risks")
8263 .and_then(Value::as_array)
8264 .unwrap()
8265 .is_empty(),
8266 "a gitignored file must not create a risk: {report}"
8267 );
8268 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
8269 }
8270
8271 #[tokio::test]
8272 async fn close_safety_check_treats_a_missing_path_as_already_removed() {
8273 // The phase-1 check on a path that no longer exists reports it removable
8274 // with no risks (so the idempotent execute proceeds with no dialog).
8275 let svc = WorktreesService::new();
8276 let report = svc
8277 .handle(
8278 "close",
8279 json!({ "path": "/no/such/worktree/xyzzy", "remove": true }),
8280 )
8281 .await
8282 .unwrap();
8283 assert_eq!(report.get("removable").and_then(Value::as_bool), Some(true));
8284 assert_eq!(report.get("is_main").and_then(Value::as_bool), Some(false));
8285 assert!(report
8286 .get("risks")
8287 .and_then(Value::as_array)
8288 .unwrap()
8289 .is_empty());
8290 let info = report.get("info").and_then(Value::as_array).unwrap();
8291 assert!(info
8292 .iter()
8293 .any(|r| r.get("kind").and_then(Value::as_str) == Some("already-removed")));
8294 }
8295
8296 #[tokio::test]
8297 async fn close_phase1_errors_on_a_non_git_worktree_path() {
8298 // An existing directory that is *not* a git worktree makes `git_safety`
8299 // fail; the error must propagate (rather than delete an unknown dir),
8300 // exercising the phase-1 `?` audit-and-return path (#1364). The ERROR
8301 // audit line itself is unit-tested via `log_close_error`.
8302 let dir = tempfile::tempdir().unwrap();
8303 let svc = WorktreesService::new();
8304 let result = svc
8305 .handle("close", json!({ "path": dir.path(), "remove": true }))
8306 .await;
8307 assert!(
8308 result.is_err(),
8309 "a non-git-worktree target must error the safety check, got: {result:?}"
8310 );
8311 }
8312
8313 #[tokio::test]
8314 async fn close_refuses_a_locked_worktree() {
8315 // A locked worktree (git worktree lock) must be refused, not forced past
8316 // (failure mode #6), and left on disk.
8317 let (main, _wtp, wt_path) = repo_with_linked_worktree();
8318 let main_repo = Repository::open(main.path()).unwrap();
8319 main_repo
8320 .find_worktree("feature")
8321 .unwrap()
8322 .lock(Some("under test"))
8323 .unwrap();
8324
8325 let svc = WorktreesService::new();
8326 let err = svc
8327 .handle(
8328 "close",
8329 json!({ "path": wt_path, "remove": true, "confirmed": true }),
8330 )
8331 .await
8332 .unwrap_err();
8333 assert!(
8334 err.to_string().contains("locked"),
8335 "expected a locked error: {err}"
8336 );
8337 assert!(wt_path.exists(), "a locked worktree must not be removed");
8338 }
8339
8340 #[tokio::test]
8341 async fn close_safety_check_does_not_flag_a_detached_head_reachable_from_a_branch() {
8342 // A detached HEAD that still sits on a commit a branch points to loses
8343 // nothing on removal, so it must NOT produce an unreachable-commits risk
8344 // (the false-positive the reachability walk guards against).
8345 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
8346 let wt_repo = Repository::open(&wt_path).unwrap();
8347 // The worktree is on `feature`; detach HEAD onto its current tip, which
8348 // the `feature` branch still references.
8349 let tip = wt_repo.head().unwrap().target().unwrap();
8350 wt_repo.set_head_detached(tip).unwrap();
8351 assert!(wt_repo.head_detached().unwrap());
8352
8353 let svc = WorktreesService::new();
8354 let report = svc
8355 .handle("close", json!({ "path": wt_path, "remove": true }))
8356 .await
8357 .unwrap();
8358 let risks = report.get("risks").and_then(Value::as_array).unwrap();
8359 assert!(
8360 !risks
8361 .iter()
8362 .any(|r| r.get("kind").and_then(Value::as_str) == Some("unreachable-commits")),
8363 "a detached HEAD reachable from a branch must not be flagged: {report}"
8364 );
8365 }
8366
8367 #[test]
8368 fn worktree_name_for_path_resolves_a_real_worktree_and_errors_otherwise() {
8369 let (main, _wtp, wt_path) = repo_with_linked_worktree();
8370 let main_repo = Repository::open(main.path()).unwrap();
8371 // The real linked worktree resolves to its registered name.
8372 assert_eq!(
8373 worktree_name_for_path(&main_repo, &canonical(&wt_path)).unwrap(),
8374 "feature"
8375 );
8376 // A path that is not one of this repo's worktrees is the defensive
8377 // "not registered" error (the guard behind removal).
8378 let err =
8379 worktree_name_for_path(&main_repo, Path::new("/no/such/worktree/xyzzy")).unwrap_err();
8380 assert!(
8381 err.to_string().contains("not registered"),
8382 "expected a not-registered error: {err}"
8383 );
8384 }
8385
8386 #[test]
8387 fn count_dirty_untracked_degrades_to_zero_on_an_unreadable_index() {
8388 // A corrupt index makes `statuses()` fail; the count degrades to (0, 0)
8389 // rather than sinking the whole safety check.
8390 let (_main, _wtp, wt_path) = repo_with_linked_worktree();
8391 let repo = Repository::open(&wt_path).unwrap();
8392 std::fs::write(repo.path().join("index"), b"not a valid git index").unwrap();
8393 // Confirm the corruption actually breaks status enumeration, so the
8394 // count is exercising the error-degradation branch (not an empty repo).
8395 assert!(
8396 repo.statuses(Some(&mut StatusOptions::new())).is_err(),
8397 "a corrupt index should make statuses() fail"
8398 );
8399 assert_eq!(count_dirty_untracked(&repo), (0, 0));
8400 }
8401}