vcs_watch/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-watch` — filesystem-watch a git/jj repository and emit typed state-change
4//! events.
5//!
6//! A [`RepoWatcher`] watches a repository's `.git`/`.jj` state directory (and,
7//! optionally, the working tree), **debounces** the burst of writes a VCS
8//! operation makes, **re-queries** the repo state through
9//! [`vcs-core`](vcs_core)'s batched [`snapshot`](vcs_core::Repo::snapshot), and
10//! **diffs** it against the previous state to yield typed [`RepoEvent`]s. Each
11//! settled change arrives as a [`RepoChange`] carrying both the new
12//! [`RepoSnapshot`] (to render a prompt/status line) and the deltas (to react).
13//! It's the foundation for prompts, status bars, TUIs, and repo daemons.
14//!
15//! Re-query-and-diff — rather than interpreting raw filesystem events — is what
16//! makes it robust: git's ref temp-file renames, `index.lock` churn, and reflog
17//! noise all just coalesce into one "re-check the settled state" instead of being
18//! (mis)read as events. Noise that doesn't move observable state emits nothing,
19//! and every emission carries the true current state, so a stray event can't
20//! desync the consumer.
21//!
22//! # The surface
23//!
24//! - **[`RepoWatcher`]** — a live watch over one repository. Start it with
25//! [`RepoWatcher::watch`] (defaults) or the [`Builder`]; drop it to stop the OS
26//! watch and the background task.
27//! - **[`Builder`]** ([`RepoWatcher::builder`]) — set the watch scope and timing,
28//! then [`build`](Builder::build): [`working_tree`](Builder::working_tree) to
29//! also watch the tree recursively, [`debounce`](Builder::debounce) (the quiet
30//! window), [`max_wait`](Builder::max_wait) (the re-query ceiling under a
31//! continuous stream), [`requery_timeout`](Builder::requery_timeout) (the
32//! per-re-query deadline). The [`DEFAULT_REQUERY_TIMEOUT`] et al. name the
33//! defaults.
34//! - **[`RepoEvent`]** — one typed delta, derived by diffing two snapshots:
35//! [`HeadMoved`](RepoEvent::HeadMoved),
36//! [`BranchSwitched`](RepoEvent::BranchSwitched),
37//! [`BranchCreated`](RepoEvent::BranchCreated) /
38//! [`BranchDeleted`](RepoEvent::BranchDeleted),
39//! [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged), and the
40//! upstream/ahead-behind/operation/conflict variants (`#[non_exhaustive]`).
41//! - **[`RepoChange`]** — a settled change: the fresh [`RepoSnapshot`] (render a
42//! status line off it) plus the non-empty `events` vec (react to it).
43//! - **Consumption** — pull changes with [`recv`](RepoWatcher::recv)
44//! (`Option<RepoChange>`; `None` once the watch backend dies, is dropped, or
45//! otherwise ends), or, under the **`stream`** feature, poll the watcher as a
46//! `futures_core::Stream`. Both pull from the same channel and advance
47//! [`current`](RepoWatcher::current), the last-pulled snapshot. A timed-out or
48//! transiently failed re-query is **retried automatically** with bounded
49//! exponential backoff — even with no new filesystem event — so a miss on the
50//! last signal isn't stuck until the next one; a *permanent* OS-watch backend
51//! failure (e.g. the watched `.git`/`.jj` dir was removed) closes this channel,
52//! so `recv`/the stream observe it directly instead of requiring separate
53//! stats polling.
54//! - **[`WatcherStats`]** ([`stats`](RepoWatcher::stats)) — lock-free health
55//! counters (re-queries run, changes emitted, skips, retries, recoveries,
56//! terminal failures, and the last skip's [`WatcherErrorKind`]). Climbing
57//! [`skipped`](WatcherStats::skipped) with flat [`changes`](WatcherStats::changes)
58//! means a wedged repo — poll it from a health check rather than inferring
59//! health from event silence.
60//! - **[`Error`]** — a setup/build failure: a [`Vcs`](Error::Vcs) baseline
61//! re-query error, an [`Io`](Error::Io) filesystem error, or a
62//! [`Notify`](Error::Notify) filesystem-watch backend failure. The watch
63//! backend is a **private** dependency, so its failures are the opaque
64//! [`WatchError`] — classify them (`is_path_not_found` / `is_watch_limit` /
65//! `io_error`) and source-chain them through `vcs-watch` alone, with no direct
66//! dependency on the third-party watch crate to keep version-matched.
67//!
68//! # Recipes
69//!
70//! Watch with the defaults and react to each settled change:
71//!
72//! ```no_run
73//! use vcs_core::Repo;
74//! use vcs_watch::RepoWatcher;
75//! # async fn run() -> vcs_watch::Result<()> {
76//! let repo = Repo::discover(".")?;
77//! let mut watcher = RepoWatcher::watch(repo).await?;
78//! while let Some(change) = watcher.recv().await {
79//! for event in &change.events {
80//! println!("{event:?}");
81//! }
82//! // `change.snapshot` is the fresh full state — render a status line off it.
83//! }
84//! # Ok(()) }
85//! ```
86//!
87//! Under the **`stream`** feature the watcher *is* a `futures_core::Stream`,
88//! so it drops into stream combinators and `tokio::select!` directly (needs
89//! `futures`/`tokio-stream`'s `StreamExt` in scope):
90//!
91//! ```ignore
92//! use futures::StreamExt;
93//! use vcs_core::Repo;
94//! use vcs_watch::RepoWatcher;
95//! # async fn run() -> vcs_watch::Result<()> {
96//! let repo = Repo::discover(".")?;
97//! let mut watcher = RepoWatcher::watch(repo).await?;
98//! while let Some(change) = watcher.next().await {
99//! println!("{} event(s)", change.events.len());
100//! }
101//! # Ok(()) }
102//! ```
103//!
104//! **Runtime:** unlike the rest of the toolkit (which hides tokio behind
105//! `processkit`), `vcs-watch` uses **tokio at runtime** — the watch task and the
106//! debounce timer run on the caller's tokio runtime, so build/await it from
107//! within one.
108//!
109//! # Testing
110//!
111//! The debounce → ceiling → re-query pipeline is a free function over injected
112//! seams, so it is exercised hermetically on a **paused clock** (no real
113//! filesystem or sleeps); a consumer's own watch code tests the same way it tests
114//! any [`vcs-core`](vcs_core) consumer — build the [`Repo`](vcs_core::Repo) over a
115//! fake runner (processkit's `ScriptedRunner`) so the re-query returns canned
116//! state. See
117//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
118//!
119//! # In-depth guide
120//!
121//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
122//! from `docs/`. See the [`guide`] module.
123
124use std::path::{Path, PathBuf};
125use std::sync::Arc;
126use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
127use std::time::Duration;
128
129use notify::{RecursiveMode, Watcher};
130use tokio::sync::mpsc;
131use vcs_core::{BackendKind, VcsRepo};
132
133mod error;
134mod event;
135
136pub use error::{Error, Result, WatchError};
137pub use event::{RepoChange, RepoEvent};
138// Re-export the snapshot types a consumer reads off a `RepoChange`, so depending
139// on `vcs-watch` alone suffices.
140pub use vcs_core::{OperationState, RepoSnapshot};
141// Re-export `processkit` so a `vcs-watch`-only consumer can name the
142// `Error::processkit_error()` return type without a direct `processkit`
143// dependency (mirrors `vcs_core::processkit` / `vcs_forge::processkit`).
144pub use processkit;
145
146/// Default quiet window: a re-query fires once the watched dir has been silent
147/// for this long after the last event.
148const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(250);
149/// Default ceiling: even under a continuous stream of events, re-query at least
150/// this often (so a long bulk operation still reports progress).
151const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(1);
152/// Upper clamp for [`max_wait`](Builder::max_wait) when it is turned into an
153/// `Instant` deadline. `Instant + Duration` *panics* on overflow, and `max_wait`
154/// is caller-settable with no bound (`.max_wait(Duration::MAX)` is a natural
155/// "disable the ceiling" idiom), so cap the addend at an effectively-unbounded
156/// one year — a huge value then disables the ceiling instead of panicking the
157/// spawned watch loop, which would drop the output channel and kill the watcher
158/// silently.
159const MAX_WAIT_CEILING: Duration = Duration::from_secs(60 * 60 * 24 * 365);
160/// Default deadline on a single re-query (`snapshot` + branch list): a wedged
161/// command (e.g. a held `index.lock` with no client timeout configured) is
162/// killed and skipped instead of stalling the watch loop forever.
163pub const DEFAULT_REQUERY_TIMEOUT: Duration = Duration::from_secs(30);
164/// Bounded output channel: a slow consumer applies backpressure (the loop pauses
165/// re-querying), and pending filesystem signals coalesce into one catch-up query.
166const OUTPUT_CAPACITY: usize = 64;
167const REQUERY_RETRY_LIMIT: u32 = 3;
168const REQUERY_RETRY_BACKOFF: Duration = Duration::from_millis(100);
169const REQUERY_RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5);
170
171#[derive(Clone, Copy)]
172enum WatchSignal {
173 Change,
174 BackendFailed,
175}
176
177/// The timing/capacity knobs the background loop runs under — bundled so the
178/// loop signature stays small and the hermetic tests can vary them (notably
179/// `output_capacity`, which the backpressure test shrinks to 1).
180struct LoopConfig {
181 debounce: Duration,
182 max_wait: Duration,
183 /// `None` disables the per-re-query deadline.
184 requery_timeout: Option<Duration>,
185 /// Whether a re-query may snapshot the jj working copy (opt-in mutation) or
186 /// must stay read-only (the default). See [`Builder::snapshot_working_copy`].
187 snapshot_working_copy: bool,
188 output_capacity: usize,
189 retry_limit: u32,
190 retry_backoff: Duration,
191}
192
193/// Builder for a [`RepoWatcher`] — set the watch scope and debounce timing, then
194/// [`build`](Builder::build).
195pub struct Builder {
196 repo: Box<dyn VcsRepo>,
197 working_tree: bool,
198 snapshot_working_copy: bool,
199 debounce: Duration,
200 max_wait: Duration,
201 requery_timeout: Option<Duration>,
202}
203
204impl Builder {
205 /// Also watch the **working tree** recursively, so a bare unstaged edit
206 /// (`vim file`) fires [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged)
207 /// immediately. Off by default (only the `.git`/`.jj` state dir is watched,
208 /// which catches an unstaged edit once it touches the index / a jj snapshot).
209 ///
210 /// Note: `notify` is `.gitignore`-unaware, so this also watches ignored and
211 /// build directories — heavier on a large tree.
212 ///
213 /// **jj note:** on jj, a bare working-tree edit only becomes an observable
214 /// state change once *something* snapshots the working copy. The re-query is
215 /// **read-only by default** (it must not itself snapshot — see
216 /// [`snapshot_working_copy`](Self::snapshot_working_copy)), so watching the
217 /// tree alone will not surface an unsnapshotted edit as a
218 /// [`WorkingCopyChanged`](RepoEvent::WorkingCopyChanged): the event fires once
219 /// a jj command (or another watcher opted into
220 /// [`snapshot_working_copy`](Self::snapshot_working_copy)) records it. Opt into
221 /// [`snapshot_working_copy(true)`](Self::snapshot_working_copy) to have the
222 /// re-query itself snapshot, at the cost of the watcher recording jj
223 /// operations.
224 pub fn working_tree(mut self, yes: bool) -> Self {
225 self.working_tree = yes;
226 self
227 }
228
229 /// Whether each re-query may let **jj snapshot the working copy** — off by
230 /// default, which keeps the watcher a pure *observer*.
231 ///
232 /// By default (`false`) the re-query is **read-only**: on jj it passes
233 /// `--ignore-working-copy` (via
234 /// [`Repo::snapshot_readonly`](vcs_core::Repo::snapshot_readonly)), so
235 /// observing the repo records **no** jj operation and never moves `@`. This is
236 /// almost always what you want: an ordinary jj query snapshots the working
237 /// copy as a side effect (taking the working-copy lock, recording an
238 /// operation, possibly moving `@`), so a naive watcher would *mutate* the very
239 /// state it reports — and, worse, a [`requery_timeout`](Self::requery_timeout)
240 /// firing mid-snapshot would abort that mutation.
241 ///
242 /// The trade-off (jj only): a bare working-tree edit that no jj command has
243 /// snapshotted yet is **not** reflected until a real jj operation records it.
244 /// If your consumer genuinely needs to observe such unsnapshotted edits (e.g.
245 /// a live "dirty" indicator driven purely by filesystem edits), set this
246 /// `true`: each re-query then snapshots the working copy (via
247 /// [`Repo::snapshot`](vcs_core::Repo::snapshot)), **recording a jj operation
248 /// and possibly moving `@`** — an explicit, opt-in mutation, not a hidden side
249 /// effect of reading. Pair it with [`working_tree(true)`](Self::working_tree)
250 /// so the tree edits actually trigger a re-query.
251 ///
252 /// On **git** this knob has no effect — git's status/branch queries never
253 /// record operations or move refs, so both modes behave identically.
254 pub fn snapshot_working_copy(mut self, yes: bool) -> Self {
255 self.snapshot_working_copy = yes;
256 self
257 }
258
259 /// The quiet window: re-query once the watched dir has been silent this long
260 /// after the last event (default 250 ms). Coalesces an operation's write
261 /// burst into one re-check.
262 pub fn debounce(mut self, window: Duration) -> Self {
263 self.debounce = window;
264 self
265 }
266
267 /// The ceiling on how long a continuous event stream defers the re-query
268 /// (default 1 s) — a long bulk operation still reports at this cadence.
269 pub fn max_wait(mut self, ceiling: Duration) -> Self {
270 self.max_wait = ceiling;
271 self
272 }
273
274 /// Deadline on a single re-query (the `snapshot` + branch-list pair), default
275 /// [`DEFAULT_REQUERY_TIMEOUT`] (30 s); `None` disables it. Orthogonal to
276 /// [`max_wait`](Self::max_wait): that bounds how long signals may *defer* a
277 /// re-query, this bounds how long one re-query may *run*. On overrun the
278 /// spawned commands are killed (kill-on-drop) and the re-query is retried
279 /// three times with bounded exponential backoff, even if no new filesystem
280 /// event arrives.
281 ///
282 /// It **also bounds the startup baseline** captured by [`build`](Self::build): a
283 /// baseline that overruns fails `build()` with a transient `Io` `TimedOut`
284 /// (`Error::is_transient()`), rather than hanging the caller — so a wedged repo
285 /// can't stall `build()` any more than it can stall the loop.
286 ///
287 /// Note: on a very large repository a *cold-cache* `git status` (first run
288 /// after a `gc`, or on a slow disk) can legitimately exceed the 30 s default
289 /// — raise it (or pass `None`) there; a watcher whose every re-query is
290 /// being killed shows up as climbing [`WatcherStats::skipped`] with flat
291 /// `changes`.
292 pub fn requery_timeout(mut self, timeout: Option<Duration>) -> Self {
293 self.requery_timeout = timeout;
294 self
295 }
296
297 /// Start watching. Captures the baseline state, registers the filesystem
298 /// watch, and spawns the background re-query task on the current tokio
299 /// runtime.
300 ///
301 /// The baseline capture is bounded by [`requery_timeout`](Self::requery_timeout),
302 /// so on a wedged repo `build()` returns a transient `Io` `TimedOut`
303 /// (`Error::is_transient()`) instead of hanging at startup — retry, or raise the
304 /// timeout.
305 pub async fn build(self) -> Result<RepoWatcher> {
306 let root = self.repo.root().to_path_buf();
307 // The dirs whose writes mean "re-check": the `.git`/`.jj` state dir, plus
308 // — for a linked git worktree — the *shared* git dir it points at via
309 // `commondir` (where `refs/heads/*` and `packed-refs` actually live, so
310 // branch create/delete is seen). See `state_dirs`.
311 let state_dirs = state_dirs(self.repo.kind(), &root)?;
312
313 // Bridge: notify's callback thread pushes a unit "something changed" signal
314 // per event; the debounce loop drains it. The channel is **capacity 1** and
315 // the callback uses `try_send`, so a burst *coalesces* into a single pending
316 // signal (extra events while one is pending are dropped — the loop re-queries
317 // the full snapshot anyway, so no state is lost). This bounds memory: an
318 // unbounded channel would grow without limit if the consumer stopped draining
319 // the output while a filesystem storm churned (R2). Build the watcher and
320 // register paths *before* the baseline snapshot, so a change racing the
321 // baseline is queued, not lost.
322 let (raw_tx, raw_rx) = mpsc::channel::<WatchSignal>(1);
323 let stats = Arc::new(StatsInner::default());
324 let cb_stats = Arc::clone(&stats);
325 // Sticky because the capacity-1 coalescing channel may already contain a
326 // change when notify reports its terminal backend error.
327 let watch_failed = Arc::new(AtomicBool::new(false));
328 let cb_watch_failed = Arc::clone(&watch_failed);
329 let mut watcher =
330 notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
331 // A backend error is sticky and terminal: the loop closes its
332 // public output channel so recv/Stream observes the failure
333 // without requiring stats polling. Ordinary events just mean
334 // "re-check"; their content is irrelevant.
335 if res.is_err() {
336 cb_stats.note_watch_error();
337 if !cb_watch_failed.swap(true, Ordering::AcqRel) {
338 cb_stats.note_terminal_failure();
339 }
340 }
341 // `try_send` on the capacity-1 channel: succeeds when no signal is
342 // pending, drops (coalesces) when one already is. Never blocks the
343 // notify callback thread; `Err` (full or loop-ended) is intentionally
344 // ignored.
345 let signal = if res.is_err() {
346 WatchSignal::BackendFailed
347 } else {
348 WatchSignal::Change
349 };
350 let _ = raw_tx.try_send(signal);
351 })?;
352 if self.working_tree {
353 watcher.watch(&root, RecursiveMode::Recursive)?;
354 // A worktree gitlink puts the real (private and shared) git dirs
355 // outside `root`; cover any not already under the recursive root watch.
356 for dir in &state_dirs {
357 if !dir.starts_with(&root) {
358 watcher.watch(dir, RecursiveMode::Recursive)?;
359 }
360 }
361 } else {
362 for dir in &state_dirs {
363 watcher.watch(dir, RecursiveMode::Recursive)?;
364 }
365 }
366
367 // Capture the baseline under the same `requery_timeout` deadline the loop
368 // applies to every re-query (R4) — otherwise a snapshot that wedges (a hung
369 // fsmonitor, a network filesystem, a held jj lock) on a `Repo` built without
370 // its own `default_timeout` would hang `build()` at startup, the very failure
371 // the loop-side deadline exists to prevent.
372 let (snapshot, branches) = capture_baseline(
373 &*self.repo,
374 self.requery_timeout,
375 self.snapshot_working_copy,
376 )
377 .await?;
378 let baseline = snapshot.clone();
379 let prev = event::WatchState::from_snapshot(&snapshot, branches);
380
381 let config = LoopConfig {
382 debounce: self.debounce,
383 max_wait: self.max_wait,
384 requery_timeout: self.requery_timeout,
385 snapshot_working_copy: self.snapshot_working_copy,
386 output_capacity: OUTPUT_CAPACITY,
387 retry_limit: REQUERY_RETRY_LIMIT,
388 retry_backoff: REQUERY_RETRY_BACKOFF,
389 };
390 let (out_tx, out_rx) = mpsc::channel::<RepoChange>(config.output_capacity);
391 let task = tokio::spawn(watch_loop(
392 self.repo,
393 raw_rx,
394 out_tx,
395 prev,
396 config,
397 Arc::clone(&stats),
398 watch_failed,
399 ));
400
401 Ok(RepoWatcher {
402 rx: out_rx,
403 current: baseline,
404 stats,
405 _watcher: watcher,
406 task,
407 })
408 }
409}
410
411// --- Watcher health counters --------------------------------------------------
412
413/// What the last skipped re-query failed on (see [`WatcherStats::last_error`]).
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415#[non_exhaustive]
416pub enum WatcherErrorKind {
417 /// The snapshot re-query returned an error (e.g. a transiently held lock).
418 Snapshot,
419 /// The branch-list re-query returned an error.
420 Branches,
421 /// The re-query exceeded [`Builder::requery_timeout`] and was killed.
422 Timeout,
423}
424
425/// A cheap point-in-time copy of the watcher's health counters — see
426/// [`RepoWatcher::stats`]. Lets a long-running consumer notice a watcher that is
427/// silently skipping re-queries (e.g. a permanently wedged repository) instead
428/// of inferring health from event silence.
429#[derive(Debug, Clone, Copy)]
430#[non_exhaustive]
431pub struct WatcherStats {
432 /// Re-query attempts started, including automatic retry attempts.
433 pub requeries: u64,
434 /// Re-queries that emitted a [`RepoChange`] (the rest found no difference).
435 pub changes: u64,
436 /// Re-queries skipped — transient query failures plus deadline overruns.
437 pub skipped: u64,
438 /// Automatic re-query retries scheduled after transient failures/timeouts.
439 pub retries: u64,
440 /// Failed re-query sequences that later succeeded during automatic retry.
441 pub recoveries: u64,
442 /// Terminal filesystem-watch backend failures. When this increments, the
443 /// output channel is closed and [`RepoWatcher::recv`] returns `None`.
444 pub terminal_failures: u64,
445 /// What the most recent skip failed on; `None` when nothing was ever skipped.
446 pub last_error: Option<WatcherErrorKind>,
447 /// Filesystem-watch **errors** reported by the OS backend (via `notify`). A
448 /// non-zero — especially *climbing* — count means the underlying watch is
449 /// failing: most often the watched `.git`/`.jj` directory was **removed and
450 /// re-created** (a re-clone / `jj git init`), which invalidates the OS watch on
451 /// the old directory. Such a reported error terminates the watch: `recv`
452 /// returns `None` (and the stream ends), so the consumer can rebuild it.
453 ///
454 /// **Best-effort, platform-dependent.** It is reliable on **Windows**, where
455 /// removing the watched directory fails `ReadDirectoryChangesW` and `notify`
456 /// reports an error. On **Linux** (`inotify`) a removed/re-created directory may
457 /// surface as an ordinary event or a silent watch teardown rather than an error,
458 /// so `watch_errors` can stay `0` even as the watcher goes deaf — don't rely on
459 /// it as the sole liveness signal there.
460 pub watch_errors: u64,
461}
462
463/// Lock-free counter cell shared between the loop and `stats()` readers. Relaxed
464/// ordering is enough: the counters are independent monotonic telemetry, not a
465/// synchronization protocol.
466#[derive(Default)]
467struct StatsInner {
468 requeries: AtomicU64,
469 changes: AtomicU64,
470 skipped: AtomicU64,
471 /// 0 = none, else `WatcherErrorKind as u8 + 1`.
472 last_error: AtomicU8,
473 watch_errors: AtomicU64,
474 retries: AtomicU64,
475 recoveries: AtomicU64,
476 terminal_failures: AtomicU64,
477}
478
479impl StatsInner {
480 fn note_requery(&self) {
481 self.requeries.fetch_add(1, Ordering::Relaxed);
482 }
483
484 fn note_change(&self) {
485 self.changes.fetch_add(1, Ordering::Relaxed);
486 }
487
488 fn note_watch_error(&self) {
489 self.watch_errors.fetch_add(1, Ordering::Relaxed);
490 }
491
492 fn note_retry(&self) {
493 self.retries.fetch_add(1, Ordering::Relaxed);
494 }
495
496 fn note_recovery(&self) {
497 self.recoveries.fetch_add(1, Ordering::Relaxed);
498 }
499
500 fn note_terminal_failure(&self) {
501 self.terminal_failures.fetch_add(1, Ordering::Relaxed);
502 }
503
504 fn note_skip(&self, kind: WatcherErrorKind) {
505 self.skipped.fetch_add(1, Ordering::Relaxed);
506 let code = match kind {
507 WatcherErrorKind::Snapshot => 1,
508 WatcherErrorKind::Branches => 2,
509 WatcherErrorKind::Timeout => 3,
510 };
511 self.last_error.store(code, Ordering::Relaxed);
512 }
513
514 fn snapshot(&self) -> WatcherStats {
515 let last_error = match self.last_error.load(Ordering::Relaxed) {
516 1 => Some(WatcherErrorKind::Snapshot),
517 2 => Some(WatcherErrorKind::Branches),
518 3 => Some(WatcherErrorKind::Timeout),
519 _ => None,
520 };
521 WatcherStats {
522 requeries: self.requeries.load(Ordering::Relaxed),
523 changes: self.changes.load(Ordering::Relaxed),
524 skipped: self.skipped.load(Ordering::Relaxed),
525 retries: self.retries.load(Ordering::Relaxed),
526 recoveries: self.recoveries.load(Ordering::Relaxed),
527 terminal_failures: self.terminal_failures.load(Ordering::Relaxed),
528 last_error,
529 watch_errors: self.watch_errors.load(Ordering::Relaxed),
530 }
531 }
532}
533
534/// A live watch over a repository, yielding [`RepoChange`]s as the repo's state
535/// changes. Dropping it stops the filesystem watch and the background task.
536pub struct RepoWatcher {
537 rx: mpsc::Receiver<RepoChange>,
538 current: RepoSnapshot,
539 stats: Arc<StatsInner>,
540 // Held to keep the OS watch alive; dropping it ends the watch (and the loop).
541 _watcher: notify::RecommendedWatcher,
542 task: tokio::task::JoinHandle<()>,
543}
544
545impl RepoWatcher {
546 /// A builder over `repo` (any [`VcsRepo`] — e.g. a [`vcs_core::Repo`]).
547 pub fn builder(repo: impl VcsRepo + 'static) -> Builder {
548 Builder {
549 repo: Box::new(repo),
550 working_tree: false,
551 // Read-only re-query by default: an observer must not snapshot the jj
552 // working copy (record an operation / move `@`) merely by looking.
553 snapshot_working_copy: false,
554 debounce: DEFAULT_DEBOUNCE,
555 max_wait: DEFAULT_MAX_WAIT,
556 requery_timeout: Some(DEFAULT_REQUERY_TIMEOUT),
557 }
558 }
559
560 /// Start watching `repo` with the defaults (state dir only, 250 ms debounce).
561 pub async fn watch(repo: impl VcsRepo + 'static) -> Result<RepoWatcher> {
562 Self::builder(repo).build().await
563 }
564
565 /// Await the next settled change. Returns `None` when the filesystem backend
566 /// reports a terminal error, the watcher is dropped, or its task otherwise
567 /// ends. A backend error also increments
568 /// [`WatcherStats::terminal_failures`].
569 pub async fn recv(&mut self) -> Option<RepoChange> {
570 let change = self.rx.recv().await?;
571 self.current = change.snapshot.clone();
572 Some(change)
573 }
574
575 /// The most recent known snapshot — the baseline captured at
576 /// [`build`](Builder::build), then the snapshot from each [`recv`](Self::recv).
577 /// It advances **only when you call [`recv`](Self::recv)**, so it is as fresh
578 /// as your last `recv`, not a live view.
579 pub fn current(&self) -> &RepoSnapshot {
580 &self.current
581 }
582
583 /// The watcher's health counters (re-queries run / changes emitted / skips,
584 /// retry/recovery/terminal outcomes, the last skip, and OS-watch errors).
585 /// Cheap relaxed-atomic
586 /// reads — poll it from a health check or log it periodically; a climbing
587 /// [`skipped`](WatcherStats::skipped) with flat
588 /// [`changes`](WatcherStats::changes) means the repository is wedged, and a
589 /// non-zero [`terminal_failures`](WatcherStats::terminal_failures) means the
590 /// output channel has terminated after an OS-watch backend error.
591 pub fn stats(&self) -> WatcherStats {
592 self.stats.snapshot()
593 }
594}
595
596/// Yields each settled [`RepoChange`] as a stream item (the `stream` feature).
597/// Equivalent to looping [`recv`](RepoWatcher::recv) — both pull from the same
598/// underlying channel (an item is delivered to whichever is polled first, never
599/// duplicated) and both advance [`current`](RepoWatcher::current).
600#[cfg(feature = "stream")]
601#[cfg_attr(docsrs, doc(cfg(feature = "stream")))]
602impl futures_core::Stream for RepoWatcher {
603 type Item = RepoChange;
604
605 fn poll_next(
606 self: std::pin::Pin<&mut Self>,
607 cx: &mut std::task::Context<'_>,
608 ) -> std::task::Poll<Option<RepoChange>> {
609 // All fields are Unpin, so the watcher is Unpin and get_mut is sound.
610 let this = self.get_mut();
611 match this.rx.poll_recv(cx) {
612 std::task::Poll::Ready(Some(change)) => {
613 this.current = change.snapshot.clone();
614 std::task::Poll::Ready(Some(change))
615 }
616 other => other,
617 }
618 }
619}
620
621impl Drop for RepoWatcher {
622 fn drop(&mut self) {
623 // The dropped `_watcher` already closes the signal channel (ending the
624 // loop); abort is belt-and-braces for prompt teardown.
625 self.task.abort();
626 }
627}
628
629/// The batched state read a re-query (and the startup baseline) performs: the
630/// snapshot plus the local-branch set. Routed through the **read-only** facade
631/// methods by default (`snapshot_working_copy == false`) so an observer never
632/// snapshots the jj working copy — no operation recorded, `@` unmoved. When the
633/// consumer opts into [`Builder::snapshot_working_copy`], it uses the ordinary
634/// (working-copy-snapshotting) facade methods instead, an explicit mutation.
635///
636/// The two calls are sequenced (branches after the snapshot) so both reflect the
637/// same observation, matching the previous behaviour.
638async fn read_state(
639 repo: &dyn VcsRepo,
640 snapshot_working_copy: bool,
641) -> vcs_core::Result<(vcs_core::RepoSnapshot, Vec<String>)> {
642 if snapshot_working_copy {
643 let snapshot = repo.snapshot().await?;
644 let branches = repo.local_branches().await?;
645 Ok((snapshot, branches))
646 } else {
647 let snapshot = repo.snapshot_readonly().await?;
648 let branches = repo.local_branches_readonly().await?;
649 Ok((snapshot, branches))
650 }
651}
652
653/// Capture the startup baseline (snapshot + local branches) under `requery_timeout`
654/// (R4). A `Some(limit)` bounds the whole capture with `tokio::time::timeout`; on
655/// expiry it returns [`Error::Io`] `TimedOut` and dropping the future kills the
656/// underlying process (kill-on-drop), exactly as the loop does for a re-query — so a
657/// wedged snapshot can't hang `build()` forever. `None` leaves it unbounded.
658///
659/// `snapshot_working_copy` picks the read-only vs working-copy-snapshotting facade
660/// methods (see [`read_state`]), so the baseline is captured under the **same**
661/// observation contract the loop then uses for every re-query.
662async fn capture_baseline(
663 repo: &dyn VcsRepo,
664 requery_timeout: Option<Duration>,
665 snapshot_working_copy: bool,
666) -> Result<(vcs_core::RepoSnapshot, Vec<String>)> {
667 let query = async {
668 read_state(repo, snapshot_working_copy)
669 .await
670 .map_err(Error::from)
671 };
672 match requery_timeout {
673 Some(limit) => match tokio::time::timeout(limit, query).await {
674 Ok(result) => result,
675 Err(_elapsed) => Err(Error::Io(std::io::Error::new(
676 std::io::ErrorKind::TimedOut,
677 format!("baseline snapshot exceeded the {limit:?} requery_timeout"),
678 ))),
679 },
680 None => query.await,
681 }
682}
683
684/// The background loop: coalesce a burst of filesystem signals, re-query the
685/// settled state, diff against the previous, and emit a [`RepoChange`] when
686/// anything changed.
687///
688/// A free function over plain channels + a [`VcsRepo`] (not a method) on
689/// purpose: the hermetic pipeline tests below drive it directly — a fake signal
690/// channel in, a `ScriptedRunner`-backed `Repo`, a paused tokio clock — pinning
691/// the debounce/ceiling/skip semantics without any real filesystem or process.
692async fn watch_loop(
693 repo: Box<dyn VcsRepo>,
694 mut raw_rx: mpsc::Receiver<WatchSignal>,
695 out_tx: mpsc::Sender<RepoChange>,
696 mut prev: event::WatchState,
697 config: LoopConfig,
698 stats: Arc<StatsInner>,
699 watch_failed: Arc<AtomicBool>,
700) {
701 'watch: loop {
702 // Block until the first signal (or exit when the watcher is dropped).
703 match raw_rx.recv().await {
704 None | Some(WatchSignal::BackendFailed) => return,
705 Some(WatchSignal::Change) if watch_failed.load(Ordering::Acquire) => return,
706 Some(WatchSignal::Change) => {}
707 }
708 // Coalesce the burst: reset a `debounce` quiet-timer on every new signal,
709 // but never wait past `max_wait` total. The dedicated `sleep_until` arm
710 // makes the ceiling exact (it fires even when no further signal arrives);
711 // the in-arm deadline check guards against a signal stream so dense that
712 // the `biased` select never polls the timer arms.
713 if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
714 return;
715 }
716 // Clamp the addend: `Instant + Duration` panics on overflow, and a huge
717 // caller `max_wait` (e.g. `Duration::MAX`) must disable the ceiling, not
718 // crash the loop. See [`MAX_WAIT_CEILING`].
719 let deadline = tokio::time::Instant::now() + config.max_wait.min(MAX_WAIT_CEILING);
720 loop {
721 tokio::select! {
722 biased;
723 sig = raw_rx.recv() => {
724 match sig {
725 None | Some(WatchSignal::BackendFailed) => return,
726 Some(WatchSignal::Change) => {}
727 }
728 if watch_failed.load(Ordering::Acquire) {
729 return;
730 }
731 // Collapse the queued backlog: under a notify storm each
732 // queued unit signal would otherwise cost a select iteration
733 // that re-creates BOTH timer futures — a burst is one
734 // "still busy" observation, not N.
735 if drain(&mut raw_rx) {
736 return;
737 }
738 if tokio::time::Instant::now() >= deadline {
739 break; // ceiling reached — re-query now
740 }
741 // else: another event — loop resets the quiet timer
742 }
743 _ = tokio::time::sleep_until(deadline) => break, // ceiling
744 _ = tokio::time::sleep(config.debounce) => break, // settled
745 }
746 }
747
748 // Re-query the settled state, bounded by the configured deadline — a
749 // wedged command (a held `index.lock` on a client with no timeout) must
750 // not stall the watch forever. Dropping the overrun future kills the
751 // spawned process tree (processkit's kill-on-drop group), so a timed-out
752 // query leaves no orphan. Failures and overruns are transient skips:
753 // counted, traced, and retried with bounded exponential backoff.
754 //
755 // Deadline safety (jj): the default re-query is **read-only**
756 // (`snapshot_working_copy == false` → `snapshot_readonly`/
757 // `local_branches_readonly`, i.e. jj `--ignore-working-copy`), so it takes
758 // no working-copy lock and records no operation — a `requery_timeout`
759 // kill-on-drop can only interrupt a pure read, never a working-copy
760 // snapshot mid-write. Only the explicit opt-in
761 // (`snapshot_working_copy == true`) runs a mutating snapshot here, and that
762 // is the caller's documented choice, not a read masquerading as read-only.
763 let mut retry = 0;
764 let (snapshot, branches) = loop {
765 stats.note_requery();
766 let requery = async {
767 let (snapshot, branches) = if config.snapshot_working_copy {
768 let snapshot = repo
769 .snapshot()
770 .await
771 .map_err(|e| (WatcherErrorKind::Snapshot, e))?;
772 let branches = repo
773 .local_branches()
774 .await
775 .map_err(|e| (WatcherErrorKind::Branches, e))?;
776 (snapshot, branches)
777 } else {
778 let snapshot = repo
779 .snapshot_readonly()
780 .await
781 .map_err(|e| (WatcherErrorKind::Snapshot, e))?;
782 let branches = repo
783 .local_branches_readonly()
784 .await
785 .map_err(|e| (WatcherErrorKind::Branches, e))?;
786 (snapshot, branches)
787 };
788 Ok::<_, (WatcherErrorKind, vcs_core::Error)>((snapshot, branches))
789 };
790 let outcome = match config.requery_timeout {
791 Some(limit) => match tokio::time::timeout(limit, requery).await {
792 Ok(result) => result.map_err(Some),
793 Err(_elapsed) => {
794 stats.note_skip(WatcherErrorKind::Timeout);
795 #[cfg(feature = "tracing")]
796 tracing::debug!(
797 timeout = ?limit,
798 retry,
799 "vcs-watch: re-query exceeded its deadline; scheduling retry"
800 );
801 Err(None)
802 }
803 },
804 None => requery.await.map_err(Some),
805 };
806 let result = match outcome {
807 Ok(pair) => Some(pair),
808 Err(Some((kind, _e))) => {
809 stats.note_skip(kind);
810 #[cfg(feature = "tracing")]
811 tracing::debug!(
812 error = %_e,
813 retry,
814 "vcs-watch: re-query failed; scheduling retry"
815 );
816 None
817 }
818 Err(None) => None,
819 };
820 if let Some(pair) = result {
821 if retry > 0 {
822 stats.note_recovery();
823 }
824 break pair;
825 }
826 if retry >= config.retry_limit {
827 // The bounded sequence is exhausted. A future filesystem event
828 // starts a fresh sequence, preserving long-term recovery without
829 // spinning forever on a permanently broken repository.
830 continue 'watch;
831 }
832 stats.note_retry();
833 let delay = retry_backoff(config.retry_backoff, retry);
834 retry += 1;
835 let deadline = tokio::time::Instant::now() + delay;
836 loop {
837 tokio::select! {
838 signal = raw_rx.recv() => match signal {
839 None | Some(WatchSignal::BackendFailed) => return,
840 Some(WatchSignal::Change) => {
841 if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
842 return;
843 }
844 // Coalesce new changes into the already-scheduled
845 // catch-up query, but retain the backoff deadline.
846 }
847 },
848 _ = tokio::time::sleep_until(deadline) => break,
849 }
850 }
851 };
852
853 if watch_failed.load(Ordering::Acquire) {
854 return;
855 }
856 let next = event::WatchState::from_snapshot(&snapshot, branches);
857 let events = event::diff(&prev, &next);
858 prev = next;
859 if events.is_empty() {
860 continue;
861 }
862 if out_tx.send(RepoChange { snapshot, events }).await.is_err() {
863 return; // receiver dropped — stop
864 }
865 stats.note_change();
866 }
867}
868
869/// Drop every already-queued unit signal — the burst is one observation. Leaves
870/// channel-closed detection to the caller's next `recv` (a drained-empty and a
871/// closed channel both just stop yielding here).
872fn drain(raw_rx: &mut mpsc::Receiver<WatchSignal>) -> bool {
873 let mut failed = false;
874 while let Ok(signal) = raw_rx.try_recv() {
875 failed |= matches!(signal, WatchSignal::BackendFailed);
876 }
877 failed
878}
879
880fn retry_backoff(base: Duration, retry: u32) -> Duration {
881 base.saturating_mul(1_u32.checked_shl(retry).unwrap_or(u32::MAX))
882 .min(REQUERY_RETRY_BACKOFF_MAX)
883}
884
885/// The directories to watch for a backend, deduplicated. Normally one — the
886/// `.git`/`.jj` state dir (see [`state_dir`]) — but a **linked git worktree** has
887/// two: its private gitdir (HEAD/index/logs) *and* the shared git dir it points
888/// at via `commondir` (`refs/heads/*` and `packed-refs`, where branch
889/// create/delete actually lands). Watching only the private dir would miss every
890/// `BranchCreated`/`BranchDeleted` on a worktree, since the shared dir is a
891/// *sibling*, not nested under it (see [`common_dir`]).
892///
893/// A colocated jj repository also watches its `.git` directory (or resolved
894/// gitlink). Git-only operations do not touch `.jj`; the `.git` event provides
895/// the re-query signal even though jj imports that data only when the next jj
896/// snapshot triggers auto-import.
897///
898/// Overlapping watches are harmless — the re-query+debounce coalesces duplicate
899/// signals — but we drop a second dir whose normalized path equals the first, so
900/// `notify` isn't asked to watch the same path twice.
901fn state_dirs(kind: BackendKind, root: &Path) -> Result<Vec<PathBuf>> {
902 let primary_state_dir = state_dir(kind, root)?;
903 let mut dirs = vec![primary_state_dir.clone()];
904
905 let mut add_git_dirs = |git_dir: PathBuf| {
906 if !dirs.iter().any(|dir| normalize(dir) == normalize(&git_dir)) {
907 dirs.push(git_dir.clone());
908 }
909 if let Some(shared) = common_dir(&git_dir)
910 && !dirs.iter().any(|dir| normalize(dir) == normalize(&shared))
911 {
912 dirs.push(shared);
913 }
914 };
915
916 match kind {
917 BackendKind::Git => add_git_dirs(primary_state_dir),
918 BackendKind::Jj if root.join(".git").exists() => {
919 add_git_dirs(state_dir(BackendKind::Git, root)?)
920 }
921 _ => {}
922 }
923 Ok(dirs)
924}
925
926/// The directory to watch for a backend: `.jj` for jj, `.git` for git. A
927/// worktree's `.git` is a gitlink *file* (`gitdir: <path>`); resolve it to the
928/// real git directory. Best-effort — falls back to the `.git` path itself.
929fn state_dir(kind: BackendKind, root: &Path) -> Result<PathBuf> {
930 match kind {
931 BackendKind::Jj => Ok(root.join(".jj")),
932 BackendKind::Git => {
933 let dot_git = root.join(".git");
934 if dot_git.is_file() {
935 let content = std::fs::read_to_string(&dot_git)?;
936 if let Some(rest) = content.trim().strip_prefix("gitdir:") {
937 let p = PathBuf::from(rest.trim());
938 return Ok(if p.is_absolute() { p } else { root.join(p) });
939 }
940 }
941 Ok(dot_git)
942 }
943 // `BackendKind` is `#[non_exhaustive]`; for an unknown future backend
944 // watch the repo root itself — coarser, but it can't miss the state dir.
945 _ => Ok(root.to_path_buf()),
946 }
947}
948
949/// The **shared** git directory for a linked worktree, or `None` for a plain
950/// repo. A linked worktree's resolved gitdir holds a `commondir` file whose
951/// content is a path (typically relative, e.g. `../..`) to the shared `.git` —
952/// where `refs/heads/*` and `packed-refs` live. We join it to the gitdir and
953/// resolve `..` (lexically, matching the no-canonicalize style of [`state_dir`],
954/// so the registered path stays plain rather than a Windows `\\?\` verbatim one).
955/// A plain repo has no `commondir` file, so this is `None` and behaviour is
956/// unchanged.
957fn common_dir(state_dir: &Path) -> Option<PathBuf> {
958 let commondir = state_dir.join("commondir");
959 let content = std::fs::read_to_string(&commondir).ok()?;
960 let rel = content.trim();
961 if rel.is_empty() {
962 return None;
963 }
964 let p = PathBuf::from(rel);
965 let joined = if p.is_absolute() {
966 p
967 } else {
968 state_dir.join(p)
969 };
970 Some(lexically_normalized(&joined))
971}
972
973/// Resolve `.`/`..` components without touching the filesystem, keeping the path
974/// in its original (non-verbatim) form — `commondir`'s `../..` plus a Windows
975/// gitdir would otherwise leave literal `..` segments in the watched path.
976fn lexically_normalized(p: &Path) -> PathBuf {
977 use std::path::Component;
978 let mut out = PathBuf::new();
979 for comp in p.components() {
980 match comp {
981 Component::ParentDir => {
982 // Pop a real segment; keep a leading `..` that can't be resolved.
983 if !out.pop() {
984 out.push(comp);
985 }
986 }
987 Component::CurDir => {}
988 other => out.push(other),
989 }
990 }
991 out
992}
993
994/// Canonicalize for comparison and strip the Windows verbatim prefix (`\\?\…`,
995/// which `canonicalize` adds), so two spellings of the same dir dedup. Mirrors
996/// `vcs-core`'s path-compare normalization; falls back to the input when the path
997/// can't be canonicalized (then equal paths still compare equal byte-for-byte).
998fn normalize(p: &Path) -> PathBuf {
999 let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1000 #[cfg(windows)]
1001 {
1002 let s = canonical.to_string_lossy();
1003 if let Some(rest) = s.strip_prefix(r"\\?\")
1004 && !rest.starts_with("UNC\\")
1005 {
1006 return PathBuf::from(rest.to_string());
1007 }
1008 }
1009 canonical
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use std::sync::atomic::{AtomicU64, Ordering};
1016
1017 static COUNTER: AtomicU64 = AtomicU64::new(0);
1018
1019 /// A unique, self-cleaning temp dir (no temp-dir crate needed for these
1020 /// hermetic helper tests — pid + counter keeps parallel tests from colliding).
1021 /// `pub(crate)`: the pipeline tests below reuse it for the scripted repo's
1022 /// on-disk git dir (the snapshot's MERGE_HEAD probe reads the filesystem).
1023 pub(crate) struct Scratch(pub(crate) PathBuf);
1024 impl Scratch {
1025 pub(crate) fn new() -> Self {
1026 let p = std::env::temp_dir().join(format!(
1027 "vcs-watch-commondir-{}-{}",
1028 std::process::id(),
1029 COUNTER.fetch_add(1, Ordering::Relaxed)
1030 ));
1031 std::fs::create_dir_all(&p).expect("create scratch dir");
1032 Scratch(p)
1033 }
1034 }
1035 impl Drop for Scratch {
1036 fn drop(&mut self) {
1037 let _ = std::fs::remove_dir_all(&self.0);
1038 }
1039 }
1040
1041 // A plain (non-worktree) git dir has no `commondir` file → no shared dir, so
1042 // behaviour is exactly today's single-dir watch.
1043 #[test]
1044 fn no_commondir_file_yields_none() {
1045 let scratch = Scratch::new();
1046 let git_dir = scratch.0.join(".git");
1047 std::fs::create_dir_all(&git_dir).expect("mkdir .git");
1048 assert_eq!(common_dir(&git_dir), None);
1049 }
1050
1051 // A linked-worktree layout: the private gitdir holds `commondir` = `../..`
1052 // (git's actual content), which must resolve to the sibling shared `.git`.
1053 #[test]
1054 fn relative_commondir_resolves_to_shared_git_dir() {
1055 let scratch = Scratch::new();
1056 let shared = scratch.0.join(".git");
1057 let private = shared.join("worktrees").join("wt");
1058 std::fs::create_dir_all(&private).expect("mkdir private gitdir");
1059 // git writes `../..` (relative to the private dir) here.
1060 std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
1061
1062 let resolved = common_dir(&private).expect("Some(shared dir)");
1063 // `<shared>/worktrees/wt` + `../..` == `<shared>` (lexically, no `..` left).
1064 assert_eq!(resolved, lexically_normalized(&shared));
1065 assert!(
1066 !resolved.to_string_lossy().contains(".."),
1067 "the `..` segments must be resolved, got {}",
1068 resolved.display()
1069 );
1070 }
1071
1072 // An absolute `commondir` (git permits it) is taken as-is.
1073 #[test]
1074 fn absolute_commondir_is_used_verbatim() {
1075 let scratch = Scratch::new();
1076 let shared = scratch.0.join("shared-git");
1077 let private = scratch.0.join("private");
1078 std::fs::create_dir_all(&private).expect("mkdir private");
1079 std::fs::write(private.join("commondir"), format!("{}\n", shared.display()))
1080 .expect("write commondir");
1081
1082 assert_eq!(common_dir(&private), Some(lexically_normalized(&shared)));
1083 }
1084
1085 // `state_dirs` returns both the private and shared dirs for a worktree, and
1086 // the shared dir is not the private one (so two distinct watches register).
1087 #[test]
1088 fn state_dirs_includes_private_and_shared_for_worktree() {
1089 let scratch = Scratch::new();
1090 let root = scratch.0.join("wt-worktree");
1091 let shared = scratch.0.join(".git");
1092 let private = shared.join("worktrees").join("wt");
1093 std::fs::create_dir_all(&private).expect("mkdir private gitdir");
1094 std::fs::create_dir_all(&root).expect("mkdir worktree root");
1095 std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
1096 // The worktree's `.git` gitlink file points at the private dir.
1097 std::fs::write(
1098 root.join(".git"),
1099 format!("gitdir: {}\n", private.display()),
1100 )
1101 .expect("write gitlink");
1102
1103 let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
1104 assert_eq!(dirs.len(), 2, "private + shared, got {dirs:?}");
1105 assert_eq!(normalize(&dirs[0]), normalize(&private));
1106 assert_eq!(normalize(&dirs[1]), normalize(&shared));
1107 }
1108
1109 #[test]
1110 fn state_dirs_includes_git_dir_for_colocated_jj_repo() {
1111 let scratch = Scratch::new();
1112 let root = scratch.0.join("colocated");
1113 std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
1114 std::fs::create_dir_all(root.join(".git")).expect("mkdir .git");
1115
1116 let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
1117 assert_eq!(dirs, vec![root.join(".jj"), root.join(".git")]);
1118 }
1119
1120 #[test]
1121 fn state_dirs_excludes_missing_git_dir_for_pure_jj_repo() {
1122 let scratch = Scratch::new();
1123 let root = scratch.0.join("pure-jj");
1124 std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
1125
1126 let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
1127 assert_eq!(dirs, vec![root.join(".jj")]);
1128 }
1129
1130 // When `commondir` resolves back to the state dir itself (degenerate), the
1131 // duplicate is dropped — we never register the same path twice.
1132 #[test]
1133 fn self_referential_commondir_is_deduped() {
1134 let scratch = Scratch::new();
1135 let git_dir = scratch.0.join(".git");
1136 std::fs::create_dir_all(&git_dir).expect("mkdir .git");
1137 // `.` resolves to the dir itself.
1138 std::fs::write(git_dir.join("commondir"), ".\n").expect("write commondir");
1139 // The gitlink points the worktree root at this very dir.
1140 let root = scratch.0.join("root");
1141 std::fs::create_dir_all(&root).expect("mkdir root");
1142 std::fs::write(
1143 root.join(".git"),
1144 format!("gitdir: {}\n", git_dir.display()),
1145 )
1146 .expect("write gitlink");
1147
1148 let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
1149 assert_eq!(dirs.len(), 1, "self-reference deduped, got {dirs:?}");
1150 }
1151
1152 // R3: verify the `watch_errors` counter→`snapshot()` plumbing (the notify
1153 // callback that calls `note_watch_error` on a backend `Err` can't be driven from
1154 // a unit test, so this pins the counter is wired in and stays independent).
1155 #[test]
1156 fn stats_counts_watch_errors_independently() {
1157 let stats = StatsInner::default();
1158 assert_eq!(stats.snapshot().watch_errors, 0);
1159 stats.note_watch_error();
1160 stats.note_watch_error();
1161 let snap = stats.snapshot();
1162 assert_eq!(snap.watch_errors, 2, "watch errors counted");
1163 assert_eq!(
1164 (snap.requeries, snap.changes, snap.skipped),
1165 (0, 0, 0),
1166 "other counters unaffected"
1167 );
1168 assert_eq!(
1169 (snap.retries, snap.recoveries, snap.terminal_failures),
1170 (0, 0, 0),
1171 "retry lifecycle counters unaffected"
1172 );
1173 assert!(snap.last_error.is_none());
1174 }
1175}
1176
1177/// Hermetic tests of the debounce → ceiling → re-query → diff pipeline itself:
1178/// `watch_loop` is driven directly with a fake signal channel, a
1179/// `ScriptedRunner`-backed `Repo`, and a **paused tokio clock** — no real
1180/// filesystem watch, no real process, no real sleeps. These pin the *loop's*
1181/// timing contract; the notify→signal bridge stays covered by the `#[ignore]`
1182/// integration tests (fake time says nothing about real OS event batching).
1183#[cfg(test)]
1184mod pipeline_tests {
1185 use super::tests::Scratch;
1186 use super::*;
1187 use processkit::ProcessRunner;
1188 use processkit::testing::{Reply, ScriptedRunner};
1189 use vcs_core::Repo;
1190 use vcs_core::vcs_git::Git;
1191
1192 /// Porcelain-v2 (NUL-separated) status output for a repo at `head`, clean.
1193 fn v2(head: &str) -> String {
1194 format!("# branch.oid {head}\0# branch.head main\0")
1195 }
1196
1197 /// The exact command set one snapshot+branches re-query issues, scripted:
1198 /// `status --porcelain=v2`, the `rev-parse --git-dir` probe (must point at a
1199 /// real dir — the op-state probe reads `MERGE_HEAD` off the filesystem), and
1200 /// `branch --no-column`.
1201 fn scripted(gitdir: &Path, head: &str) -> ScriptedRunner {
1202 ScriptedRunner::new()
1203 .on(["git", "status"], Reply::ok(v2(head)))
1204 .on(
1205 ["git", "rev-parse"],
1206 Reply::ok(format!("{}\n", gitdir.display())),
1207 )
1208 .on(["git", "branch"], Reply::ok("* main\n"))
1209 }
1210
1211 fn scripted_repo(gitdir: &Path, head: &str) -> Box<dyn VcsRepo> {
1212 Box::new(Repo::from_git(
1213 "/r",
1214 "/r",
1215 Git::with_runner(scripted(gitdir, head)),
1216 ))
1217 }
1218
1219 /// The baseline `prev` state the loop diffs against, taken through the same
1220 /// snapshot path `Builder::build` uses.
1221 async fn baseline(gitdir: &Path, head: &str) -> event::WatchState {
1222 let repo = scripted_repo(gitdir, head);
1223 let snap = repo.snapshot().await.expect("baseline snapshot");
1224 let branches = repo.local_branches().await.expect("baseline branches");
1225 event::WatchState::from_snapshot(&snap, branches)
1226 }
1227
1228 fn defaults() -> LoopConfig {
1229 LoopConfig {
1230 debounce: Duration::from_millis(250),
1231 max_wait: Duration::from_secs(1),
1232 requery_timeout: Some(Duration::from_secs(30)),
1233 // The hermetic pipeline drives a git-backed scripted repo, where
1234 // read-only and snapshotting re-queries issue the same commands; the
1235 // default (read-only) mirrors production.
1236 snapshot_working_copy: false,
1237 output_capacity: 64,
1238 retry_limit: REQUERY_RETRY_LIMIT,
1239 retry_backoff: REQUERY_RETRY_BACKOFF,
1240 }
1241 }
1242
1243 struct Harness {
1244 sig: mpsc::Sender<WatchSignal>,
1245 out: mpsc::Receiver<RepoChange>,
1246 stats: Arc<StatsInner>,
1247 watch_failed: Arc<AtomicBool>,
1248 task: tokio::task::JoinHandle<()>,
1249 }
1250
1251 impl Harness {
1252 // Mirror the production notify callback: fire-and-forget `try_send` on the
1253 // capacity-1 bridge (a pending signal coalesces the next one). `Err` (full or
1254 // loop-ended) is intentionally ignored — a still-pending signal already
1255 // triggers the re-query the caller wants.
1256 fn signal(&self) {
1257 let _ = self.sig.try_send(WatchSignal::Change);
1258 }
1259
1260 fn backend_failed(&self) {
1261 self.stats.note_watch_error();
1262 if !self.watch_failed.swap(true, Ordering::AcqRel) {
1263 self.stats.note_terminal_failure();
1264 }
1265 let _ = self.sig.try_send(WatchSignal::BackendFailed);
1266 }
1267 }
1268
1269 fn spawn_loop(repo: Box<dyn VcsRepo>, prev: event::WatchState, config: LoopConfig) -> Harness {
1270 let (sig, raw_rx) = mpsc::channel(1);
1271 let (out_tx, out) = mpsc::channel(config.output_capacity);
1272 let stats = Arc::new(StatsInner::default());
1273 let watch_failed = Arc::new(AtomicBool::new(false));
1274 let task = tokio::spawn(watch_loop(
1275 repo,
1276 raw_rx,
1277 out_tx,
1278 prev,
1279 config,
1280 Arc::clone(&stats),
1281 Arc::clone(&watch_failed),
1282 ));
1283 Harness {
1284 sig,
1285 out,
1286 stats,
1287 watch_failed,
1288 task,
1289 }
1290 }
1291
1292 /// Let the loop task run to a quiescent point without advancing time —
1293 /// paused-clock auto-advance only triggers when every task idles on a timer,
1294 /// so a bounded yield burst (never a spin-until loop) is the safe way to let
1295 /// an already-runnable re-query complete.
1296 async fn settle() {
1297 for _ in 0..32 {
1298 tokio::task::yield_now().await;
1299 }
1300 }
1301
1302 // A burst of sub-debounce signals coalesces into exactly one re-query and
1303 // one emitted change.
1304 #[tokio::test(start_paused = true)]
1305 async fn debounce_coalesces_burst() {
1306 let scratch = Scratch::new();
1307 let prev = baseline(&scratch.0, "aaa").await;
1308 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1309
1310 for _ in 0..5 {
1311 h.signal();
1312 tokio::time::advance(Duration::from_millis(10)).await;
1313 }
1314 let change = h.out.recv().await.expect("one coalesced change");
1315 assert!(
1316 change
1317 .events
1318 .iter()
1319 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1320 "expected HeadMoved, got {:?}",
1321 change.events
1322 );
1323
1324 // Long quiet: nothing else arrives, and exactly one re-query ran.
1325 tokio::time::advance(Duration::from_secs(5)).await;
1326 settle().await;
1327 assert!(
1328 h.out.try_recv().is_err(),
1329 "burst must coalesce to one change"
1330 );
1331 let stats = h.stats.snapshot();
1332 assert_eq!((stats.requeries, stats.changes), (1, 1));
1333 }
1334
1335 // Signals arriving faster than the quiet window forever: the `max_wait`
1336 // ceiling still forces a re-query at its cadence (the dedicated
1337 // `sleep_until` arm — not just "on the next signal after the deadline").
1338 #[tokio::test(start_paused = true)]
1339 async fn max_wait_caps_continuous_signals() {
1340 let scratch = Scratch::new();
1341 let prev = baseline(&scratch.0, "aaa").await;
1342 let h_config = defaults();
1343 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, h_config);
1344
1345 // A pump that fires a signal every 100 ms — always inside the 250 ms
1346 // quiet window, so only the ceiling can break the burst.
1347 let pump_sig = h.sig.clone();
1348 let pump = tokio::spawn(async move {
1349 loop {
1350 // `try_send` mirrors the notify callback. `Full` means our previous
1351 // signal is still pending (coalesced) — keep pumping; `Closed` means
1352 // the loop ended — stop.
1353 if let Err(mpsc::error::TrySendError::Closed(WatchSignal::Change)) =
1354 pump_sig.try_send(WatchSignal::Change)
1355 {
1356 return;
1357 }
1358 tokio::time::sleep(Duration::from_millis(100)).await;
1359 }
1360 });
1361
1362 let change = tokio::time::timeout(Duration::from_secs(2), h.out.recv())
1363 .await
1364 .expect("the ceiling must fire within max_wait")
1365 .expect("change");
1366 assert!(
1367 change
1368 .events
1369 .iter()
1370 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1371 "got {:?}",
1372 change.events
1373 );
1374 pump.abort();
1375 }
1376
1377 // P1: a caller "disabling the ceiling" with `Duration::MAX` must not overflow
1378 // the `Instant + max_wait` deadline and panic the spawned loop (which would
1379 // drop the output channel and kill the watcher silently). The clamp keeps it
1380 // running; the debounce timer still fires normally.
1381 #[tokio::test(start_paused = true)]
1382 async fn max_wait_duration_max_does_not_panic_the_loop() {
1383 let scratch = Scratch::new();
1384 let prev = baseline(&scratch.0, "aaa").await;
1385 let config = LoopConfig {
1386 max_wait: Duration::MAX,
1387 ..defaults()
1388 };
1389 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, config);
1390 h.signal();
1391 tokio::time::advance(Duration::from_millis(300)).await; // past the 250 ms debounce
1392 let change = h
1393 .out
1394 .recv()
1395 .await
1396 .expect("the loop survives a Duration::MAX max_wait and still re-queries");
1397 assert!(!change.events.is_empty(), "got {:?}", change.events);
1398 }
1399
1400 // The base case: one signal, a quiet gap, one re-query.
1401 #[tokio::test(start_paused = true)]
1402 async fn quiet_gap_triggers_requery() {
1403 let scratch = Scratch::new();
1404 let prev = baseline(&scratch.0, "aaa").await;
1405 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1406
1407 h.signal();
1408 let change = h.out.recv().await.expect("change after the quiet gap");
1409 assert!(
1410 change
1411 .events
1412 .iter()
1413 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1414 );
1415 }
1416
1417 // A re-query that finds the same state emits nothing — but it *ran* (the
1418 // stats distinguish "no change" from "never re-queried").
1419 #[tokio::test(start_paused = true)]
1420 async fn no_change_yields_no_emission() {
1421 let scratch = Scratch::new();
1422 let prev = baseline(&scratch.0, "aaa").await;
1423 // Same head as the baseline → empty diff.
1424 let mut h = spawn_loop(scripted_repo(&scratch.0, "aaa"), prev, defaults());
1425
1426 h.signal();
1427 settle().await; // let the loop register its quiet timer first
1428 tokio::time::advance(Duration::from_millis(300)).await; // past debounce
1429 settle().await; // let the re-query run
1430
1431 let stats = h.stats.snapshot();
1432 assert_eq!((stats.requeries, stats.changes, stats.skipped), (1, 0, 0));
1433 assert!(
1434 h.out.try_recv().is_err(),
1435 "no events for an unchanged state"
1436 );
1437 }
1438
1439 /// Fails the first `status` call (a transiently held lock), then behaves —
1440 /// `ScriptedRunner` rules are stateless, so the two-phase behaviour needs a
1441 /// tiny stateful runner delegating to throwaway scripted ones.
1442 struct FlakyStatus {
1443 fails_left: AtomicU64,
1444 gitdir: PathBuf,
1445 head: &'static str,
1446 }
1447
1448 #[async_trait::async_trait]
1449 impl ProcessRunner for FlakyStatus {
1450 async fn output_string(
1451 &self,
1452 command: &processkit::Command,
1453 ) -> processkit::Result<processkit::ProcessResult<String>> {
1454 let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1455 if is_status && self.fails_left.load(Ordering::Relaxed) > 0 {
1456 self.fails_left.fetch_sub(1, Ordering::Relaxed);
1457 return Err(processkit::Error::exit(
1458 "git",
1459 128,
1460 "",
1461 "fatal: Unable to create '.git/index.lock'",
1462 ));
1463 }
1464 scripted(&self.gitdir, self.head)
1465 .output_string(command)
1466 .await
1467 }
1468 }
1469
1470 // A transient re-query failure is skipped (counted, no emission); the next
1471 // signal re-checks and recovers.
1472 #[tokio::test(start_paused = true)]
1473 async fn transient_failure_skips_then_recovers() {
1474 let scratch = Scratch::new();
1475 let prev = baseline(&scratch.0, "aaa").await;
1476 let repo = Box::new(Repo::from_git(
1477 "/r",
1478 "/r",
1479 Git::with_runner(FlakyStatus {
1480 fails_left: AtomicU64::new(1),
1481 gitdir: scratch.0.clone(),
1482 head: "bbb",
1483 }),
1484 ));
1485 let mut h = spawn_loop(repo, prev, defaults());
1486
1487 // First attempt: the snapshot fails → skip, nothing emitted.
1488 h.signal();
1489 settle().await; // loop registers the quiet timer
1490 tokio::time::advance(Duration::from_millis(300)).await;
1491 settle().await; // the (failing) re-query runs
1492 let stats = h.stats.snapshot();
1493 assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1494 assert_eq!(stats.last_error, Some(WatcherErrorKind::Snapshot));
1495 assert!(h.out.try_recv().is_err());
1496
1497 // Second signal: the lock "cleared" — the re-query recovers and emits.
1498 h.signal();
1499 let change = h.out.recv().await.expect("recovered change");
1500 assert!(
1501 change
1502 .events
1503 .iter()
1504 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1505 );
1506 let stats = h.stats.snapshot();
1507 assert_eq!((stats.requeries, stats.changes), (2, 1));
1508 }
1509
1510 /// Delays every reply by `delay` (virtual time — `tokio::time::sleep`, NOT a
1511 /// thread sleep, so the paused clock controls it). `ScriptedRunner` replies
1512 /// instantly, so this is the only way to exercise the `requery_timeout`
1513 /// wrapper — a scripted `Reply::timeout()` resolves immediately and would
1514 /// test the *error* path, not the deadline.
1515 struct Sleepy {
1516 delay: Duration,
1517 gitdir: PathBuf,
1518 head: &'static str,
1519 }
1520
1521 #[async_trait::async_trait]
1522 impl ProcessRunner for Sleepy {
1523 async fn output_string(
1524 &self,
1525 command: &processkit::Command,
1526 ) -> processkit::Result<processkit::ProcessResult<String>> {
1527 tokio::time::sleep(self.delay).await;
1528 scripted(&self.gitdir, self.head)
1529 .output_string(command)
1530 .await
1531 }
1532 }
1533
1534 /// Only the first status query is slow. Its timeout drops the sleeping
1535 /// future after the counter has advanced, so the automatic retry succeeds.
1536 struct SlowFirstStatus {
1537 slow_left: AtomicBool,
1538 delay: Duration,
1539 gitdir: PathBuf,
1540 head: &'static str,
1541 }
1542
1543 #[async_trait::async_trait]
1544 impl ProcessRunner for SlowFirstStatus {
1545 async fn output_string(
1546 &self,
1547 command: &processkit::Command,
1548 ) -> processkit::Result<processkit::ProcessResult<String>> {
1549 let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1550 if is_status && self.slow_left.swap(false, Ordering::Relaxed) {
1551 tokio::time::sleep(self.delay).await;
1552 }
1553 scripted(&self.gitdir, self.head)
1554 .output_string(command)
1555 .await
1556 }
1557 }
1558
1559 // A timeout on the final filesystem signal schedules its own retry. No new
1560 // signal is needed to observe the state that the timed-out query missed.
1561 #[tokio::test(start_paused = true)]
1562 async fn timeout_on_last_signal_recovers_via_backoff_retry() {
1563 let scratch = Scratch::new();
1564 let prev = baseline(&scratch.0, "aaa").await;
1565 let repo = Box::new(Repo::from_git(
1566 "/r",
1567 "/r",
1568 Git::with_runner(SlowFirstStatus {
1569 slow_left: AtomicBool::new(true),
1570 delay: Duration::from_secs(10),
1571 gitdir: scratch.0.clone(),
1572 head: "bbb",
1573 }),
1574 ));
1575 let config = LoopConfig {
1576 requery_timeout: Some(Duration::from_secs(5)),
1577 retry_backoff: Duration::from_secs(1),
1578 ..defaults()
1579 };
1580 let mut h = spawn_loop(repo, prev, config);
1581
1582 h.signal();
1583 settle().await;
1584 tokio::time::advance(Duration::from_millis(300)).await;
1585 settle().await;
1586 tokio::time::advance(Duration::from_secs(5)).await;
1587 settle().await;
1588 assert_eq!(
1589 (h.stats.snapshot().requeries, h.stats.snapshot().retries),
1590 (1, 1)
1591 );
1592 assert!(h.out.try_recv().is_err());
1593
1594 // Only virtual time advances here: there is deliberately no h.signal().
1595 tokio::time::advance(Duration::from_secs(1)).await;
1596 settle().await;
1597 let change = h.out.try_recv().expect("retry emits the missed change");
1598 assert!(
1599 change
1600 .events
1601 .iter()
1602 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1603 );
1604 let stats = h.stats.snapshot();
1605 assert_eq!((stats.requeries, stats.skipped, stats.retries), (2, 1, 1));
1606 assert_eq!((stats.recoveries, stats.changes), (1, 1));
1607 }
1608
1609 // Retry exhaustion is bounded and then becomes idle until another event.
1610 #[tokio::test(start_paused = true)]
1611 async fn persistent_requery_failure_exhausts_retries_without_busy_loop() {
1612 let scratch = Scratch::new();
1613 let prev = baseline(&scratch.0, "aaa").await;
1614 let repo = Box::new(Repo::from_git(
1615 "/r",
1616 "/r",
1617 Git::with_runner(FlakyStatus {
1618 fails_left: AtomicU64::new(100),
1619 gitdir: scratch.0.clone(),
1620 head: "bbb",
1621 }),
1622 ));
1623 let config = LoopConfig {
1624 retry_limit: 2,
1625 retry_backoff: Duration::from_millis(100),
1626 ..defaults()
1627 };
1628 let h = spawn_loop(repo, prev, config);
1629
1630 h.signal();
1631 settle().await;
1632 tokio::time::advance(Duration::from_millis(300)).await;
1633 settle().await;
1634 tokio::time::advance(Duration::from_millis(100)).await;
1635 settle().await;
1636 tokio::time::advance(Duration::from_millis(200)).await;
1637 settle().await;
1638 let stats = h.stats.snapshot();
1639 assert_eq!((stats.requeries, stats.skipped, stats.retries), (3, 3, 2));
1640 assert_eq!(stats.recoveries, 0);
1641
1642 tokio::time::advance(Duration::from_secs(60 * 60)).await;
1643 settle().await;
1644 assert_eq!(
1645 h.stats.snapshot().requeries,
1646 3,
1647 "exhaustion must park on the signal receiver"
1648 );
1649 }
1650
1651 // Closing the producer while parked in backoff cancels the retry promptly.
1652 #[tokio::test(start_paused = true)]
1653 async fn drop_teardown_during_retry_backoff() {
1654 let scratch = Scratch::new();
1655 let prev = baseline(&scratch.0, "aaa").await;
1656 let repo = Box::new(Repo::from_git(
1657 "/r",
1658 "/r",
1659 Git::with_runner(FlakyStatus {
1660 fails_left: AtomicU64::new(1),
1661 gitdir: scratch.0.clone(),
1662 head: "bbb",
1663 }),
1664 ));
1665 let config = LoopConfig {
1666 retry_backoff: Duration::from_secs(60 * 60),
1667 ..defaults()
1668 };
1669 let Harness {
1670 sig,
1671 mut out,
1672 stats,
1673 watch_failed: _,
1674 task,
1675 } = spawn_loop(repo, prev, config);
1676
1677 sig.try_send(WatchSignal::Change).expect("send");
1678 settle().await;
1679 tokio::time::advance(Duration::from_millis(300)).await;
1680 settle().await;
1681 assert_eq!((stats.snapshot().skipped, stats.snapshot().retries), (1, 1));
1682 drop(sig);
1683 task.await.expect("loop exits while retry timer is pending");
1684 assert!(out.recv().await.is_none());
1685 }
1686
1687 // A notify backend death is terminal through the primary API: recv/Stream
1688 // observes channel closure, without requiring separate stats polling.
1689 #[tokio::test(start_paused = true)]
1690 async fn permanent_backend_failure_closes_main_channel() {
1691 let scratch = Scratch::new();
1692 let prev = baseline(&scratch.0, "aaa").await;
1693 let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1694
1695 h.backend_failed();
1696 assert!(h.out.recv().await.is_none(), "backend death closes recv");
1697 let stats = h.stats.snapshot();
1698 assert_eq!((stats.watch_errors, stats.terminal_failures), (1, 1));
1699 assert_eq!((stats.retries, stats.recoveries), (0, 0));
1700 }
1701
1702 // A re-query exceeding the configured deadline is killed and skipped as
1703 // transient; the loop survives (a later attempt runs and is also bounded).
1704 #[tokio::test(start_paused = true)]
1705 async fn requery_timeout_skips_as_transient() {
1706 let scratch = Scratch::new();
1707 let prev = baseline(&scratch.0, "aaa").await;
1708 let repo = Box::new(Repo::from_git(
1709 "/r",
1710 "/r",
1711 Git::with_runner(Sleepy {
1712 delay: Duration::from_secs(10),
1713 gitdir: scratch.0.clone(),
1714 head: "bbb",
1715 }),
1716 ));
1717 let config = LoopConfig {
1718 requery_timeout: Some(Duration::from_secs(5)),
1719 ..defaults()
1720 };
1721 let mut h = spawn_loop(repo, prev, config);
1722
1723 h.signal();
1724 settle().await; // loop registers the quiet timer
1725 tokio::time::advance(Duration::from_millis(300)).await; // debounce
1726 settle().await; // re-query starts; Sleepy + the deadline register timers
1727 tokio::time::advance(Duration::from_secs(6)).await; // past the deadline
1728 settle().await;
1729 let stats = h.stats.snapshot();
1730 assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
1731 assert_eq!(stats.last_error, Some(WatcherErrorKind::Timeout));
1732 assert!(h.out.try_recv().is_err());
1733
1734 // The loop is alive: a second attempt runs (and times out the same way).
1735 h.signal();
1736 settle().await;
1737 tokio::time::advance(Duration::from_millis(300)).await;
1738 settle().await;
1739 tokio::time::advance(Duration::from_secs(6)).await;
1740 settle().await;
1741 assert_eq!(h.stats.snapshot().requeries, 2);
1742 }
1743
1744 // R4: the startup baseline honors `requery_timeout` — a snapshot that wedges (a
1745 // `Sleepy` repo far past the deadline) errors with `TimedOut` instead of hanging
1746 // `build()` forever. Exercises `capture_baseline` directly (the `build()` path is
1747 // only reachable with a real notify watcher).
1748 #[tokio::test(start_paused = true)]
1749 async fn baseline_capture_honors_requery_timeout() {
1750 let scratch = Scratch::new();
1751 let repo = Repo::from_git(
1752 "/r",
1753 "/r",
1754 Git::with_runner(Sleepy {
1755 delay: Duration::from_secs(10),
1756 gitdir: scratch.0.clone(),
1757 head: "bbb",
1758 }),
1759 );
1760 let err = capture_baseline(&repo, Some(Duration::from_secs(5)), false)
1761 .await
1762 .expect_err("a wedged baseline must time out, not hang");
1763 assert!(
1764 matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::TimedOut),
1765 "expected an Io TimedOut, got {err:?}"
1766 );
1767 // A wedged baseline is retryable — `build()` agrees with the loop's transient
1768 // treatment of a re-query timeout.
1769 assert!(err.is_transient(), "a baseline timeout is transient");
1770
1771 // With no deadline the same query completes (Sleepy still returns, just late);
1772 // advancing the clock lets it finish so we prove the timeout — not the repo —
1773 // is what produced the error above.
1774 let ok = capture_baseline(&repo, None, false).await;
1775 assert!(ok.is_ok(), "an unbounded baseline still succeeds: {ok:?}");
1776 }
1777
1778 // Closing the signal channel mid-debounce ends the loop promptly and closes
1779 // the output channel.
1780 #[tokio::test(start_paused = true)]
1781 async fn drop_teardown_mid_debounce() {
1782 let scratch = Scratch::new();
1783 let prev = baseline(&scratch.0, "aaa").await;
1784 let Harness {
1785 sig,
1786 mut out,
1787 stats: _,
1788 watch_failed: _,
1789 task,
1790 } = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1791
1792 sig.try_send(WatchSignal::Change).expect("send");
1793 tokio::time::advance(Duration::from_millis(100)).await; // mid-debounce
1794 drop(sig);
1795
1796 tokio::time::timeout(Duration::from_secs(1), task)
1797 .await
1798 .expect("loop ends promptly")
1799 .expect("loop task joins cleanly");
1800 assert!(out.recv().await.is_none(), "output closes with the loop");
1801 }
1802
1803 /// Reports a different head on every `status` call, so every re-query
1804 /// produces a `HeadMoved` — the emission generator the backpressure test
1805 /// needs to fill the bounded output channel.
1806 struct VaryingHead {
1807 statuses: AtomicU64,
1808 gitdir: PathBuf,
1809 }
1810
1811 #[async_trait::async_trait]
1812 impl ProcessRunner for VaryingHead {
1813 async fn output_string(
1814 &self,
1815 command: &processkit::Command,
1816 ) -> processkit::Result<processkit::ProcessResult<String>> {
1817 let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
1818 let n = if is_status {
1819 self.statuses.fetch_add(1, Ordering::Relaxed)
1820 } else {
1821 self.statuses.load(Ordering::Relaxed)
1822 };
1823 scripted(&self.gitdir, &format!("h{n}"))
1824 .output_string(command)
1825 .await
1826 }
1827 }
1828
1829 // A full output channel parks the loop at `send` (backpressure) instead of
1830 // dropping or buffering unboundedly; draining one item unparks it.
1831 #[tokio::test(start_paused = true)]
1832 async fn backpressure_parks_loop() {
1833 let scratch = Scratch::new();
1834 let prev = baseline(&scratch.0, "base").await;
1835 let repo = Box::new(Repo::from_git(
1836 "/r",
1837 "/r",
1838 Git::with_runner(VaryingHead {
1839 statuses: AtomicU64::new(0),
1840 gitdir: scratch.0.clone(),
1841 }),
1842 ));
1843 let config = LoopConfig {
1844 output_capacity: 1,
1845 ..defaults()
1846 };
1847 let mut h = spawn_loop(repo, prev, config);
1848
1849 // First change fills the capacity-1 channel.
1850 h.signal();
1851 settle().await; // loop registers the quiet timer
1852 tokio::time::advance(Duration::from_millis(300)).await;
1853 settle().await; // re-query runs; emission 1 fills the channel
1854 // Second re-query produces another change; the send parks (channel full):
1855 // the re-query ran but the emission hasn't landed.
1856 h.signal();
1857 settle().await;
1858 tokio::time::advance(Duration::from_millis(300)).await;
1859 settle().await;
1860 let stats = h.stats.snapshot();
1861 assert_eq!(
1862 (stats.requeries, stats.changes),
1863 (2, 1),
1864 "second emission must be parked on the full channel"
1865 );
1866
1867 // Draining unparks the loop; both changes arrive in order.
1868 let first = h.out.recv().await.expect("first change");
1869 assert!(
1870 first
1871 .events
1872 .iter()
1873 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1874 );
1875 let second = h.out.recv().await.expect("second change");
1876 assert!(
1877 second
1878 .events
1879 .iter()
1880 .any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
1881 );
1882 settle().await;
1883 assert_eq!(h.stats.snapshot().changes, 2);
1884 }
1885
1886 // The `stream` feature: `StreamExt::next` on the REAL `RepoWatcher` yields
1887 // what `recv` would and advances `current()` identically. The watcher is
1888 // assembled directly (same crate) around the loop harness's channel, with an
1889 // idle notify watcher standing in for the OS watch.
1890 #[cfg(feature = "stream")]
1891 #[tokio::test(start_paused = true)]
1892 async fn stream_yields_changes_and_advances_current() {
1893 use tokio_stream::StreamExt;
1894
1895 let scratch = Scratch::new();
1896 let prev = baseline(&scratch.0, "aaa").await;
1897 let h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
1898
1899 let baseline_snap = scripted_repo(&scratch.0, "aaa")
1900 .snapshot()
1901 .await
1902 .expect("baseline snapshot");
1903 let mut watcher = RepoWatcher {
1904 rx: h.out,
1905 current: baseline_snap,
1906 stats: h.stats,
1907 _watcher: notify::recommended_watcher(|_res| {}).expect("idle watcher"),
1908 task: h.task,
1909 };
1910 assert_eq!(watcher.current().head.as_deref(), Some("aaa"));
1911
1912 // `h` is partially moved into `watcher` above, so reach the remaining `sig`
1913 // field directly rather than through the `h.signal()` method (which would
1914 // borrow all of `h`).
1915 let _ = h.sig.try_send(WatchSignal::Change);
1916 let change = watcher.next().await.expect("stream item");
1917 assert!(
1918 change
1919 .events
1920 .iter()
1921 .any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
1922 "got {:?}",
1923 change.events
1924 );
1925 // Polling through the Stream advanced `current()` exactly like `recv`.
1926 assert_eq!(watcher.current().head.as_deref(), Some("bbb"));
1927 }
1928}
1929
1930// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
1931#[doc = include_str!("../docs/watch.md")]
1932#[allow(rustdoc::broken_intra_doc_links)]
1933pub mod guide {}