Skip to main content

vcs_watch/
error.rs

1//! The crate's error type: filesystem-watcher setup failures plus the underlying
2//! `vcs-core` re-query errors.
3//!
4//! The filesystem-watch backend (`notify`) is a **private** dependency: its
5//! failures surface as the opaque [`WatchError`], classified through this crate's
6//! own stable methods rather than by matching the third-party error type. So a
7//! consumer reads and source-chains a watch failure through `vcs-watch` alone —
8//! no direct `notify` dependency to keep version-matched — and a `notify` major
9//! bump stays an *internal*, non-breaking change here (the backend is not part of
10//! this crate's stability contract).
11
12use std::path::PathBuf;
13
14/// An error from setting up or running a [`RepoWatcher`](crate::RepoWatcher).
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum Error {
18    /// The filesystem watcher failed to start, or to register/deregister a
19    /// watched path. Opaque over the private backend — inspect it through
20    /// [`WatchError`]'s classifiers (reachable directly, or via
21    /// [`Error::watch_error`]) instead of the backend's own error type.
22    Notify(WatchError),
23    /// A `vcs-core` query (detection / `snapshot` / `local_branches`) failed —
24    /// chiefly while *building* the watcher (capturing the baseline state). A
25    /// re-query failure *during* watching is skipped and retried, not surfaced
26    /// here (see [`RepoWatcher`](crate::RepoWatcher)).
27    Vcs(vcs_core::Error),
28    /// A filesystem operation failed (e.g. resolving a worktree gitlink).
29    Io(std::io::Error),
30}
31
32impl Error {
33    /// Whether this wraps a **transient** failure worth retrying — an
34    /// interrupted / would-block / resource-busy io/spawn failure from the
35    /// underlying `vcs-core` query (delegates to
36    /// [`vcs_core::Error::is_transient`]), **or** a baseline-snapshot **timeout**
37    /// (`Io` `TimedOut`, raised when the startup snapshot exceeds
38    /// `requery_timeout`) — a wedged repo may un-wedge, and the loop already treats
39    /// a re-query timeout as a transient skip, so `build()` agrees. Other `Io` and
40    /// `Notify` errors are `false` (a failed OS watch registration won't fix itself
41    /// on a blind retry — classify it via [`WatchError`] and act on the cause).
42    /// Mirrors the classifier family on the other facades.
43    pub fn is_transient(&self) -> bool {
44        match self {
45            Error::Vcs(e) => e.is_transient(),
46            Error::Io(e) => e.kind() == std::io::ErrorKind::TimedOut,
47            _ => false,
48        }
49    }
50
51    /// Whether the underlying VCS binary (`git`/`jj`) **wasn't found** — a setup
52    /// problem (not installed / not on `PATH`), surfaced while building the
53    /// watcher's baseline. Delegates to [`vcs_core::Error::is_not_found`].
54    pub fn is_not_found(&self) -> bool {
55        matches!(self, Error::Vcs(e) if e.is_not_found())
56    }
57
58    /// The opaque [`WatchError`] when this is a filesystem-watch backend failure;
59    /// `None` for a `Vcs`/`Io` error. A stable accessor (mirroring
60    /// [`processkit_error`](Self::processkit_error)) so a caller — or a language
61    /// binding — can reach the watch classifiers without matching the enum
62    /// variant by hand.
63    pub fn watch_error(&self) -> Option<&WatchError> {
64        match self {
65            Error::Notify(e) => Some(e),
66            _ => None,
67        }
68    }
69
70    /// The structured underlying [`processkit::Error`], if this error came from a
71    /// VCS subprocess — flattening the two-level
72    /// `Vcs(`[`vcs_core::Error::Vcs`]`(_))` nesting so a caller (or a language
73    /// binding) can read its structured fields (`program`, plus `code`/`stdout`/
74    /// `stderr` on an `Exit`) without hand-walking it. `None` for a `Notify`/`Io`
75    /// failure or a non-subprocess `vcs-core` error (e.g. "not a repository").
76    pub fn processkit_error(&self) -> Option<&processkit::Error> {
77        match self {
78            Error::Vcs(vcs_core::Error::Vcs(e)) => Some(e),
79            _ => None,
80        }
81    }
82}
83
84impl std::fmt::Display for Error {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Error::Notify(e) => write!(f, "filesystem watch failed: {e}"),
88            Error::Vcs(e) => write!(f, "{e}"),
89            Error::Io(e) => write!(f, "{e}"),
90        }
91    }
92}
93
94impl std::error::Error for Error {
95    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96        match self {
97            Error::Notify(e) => Some(e),
98            Error::Vcs(e) => Some(e),
99            Error::Io(e) => Some(e),
100        }
101    }
102}
103
104impl From<notify::Error> for Error {
105    fn from(e: notify::Error) -> Self {
106        Error::Notify(WatchError(e))
107    }
108}
109
110impl From<vcs_core::Error> for Error {
111    fn from(e: vcs_core::Error) -> Self {
112        Error::Vcs(e)
113    }
114}
115
116impl From<std::io::Error> for Error {
117    fn from(e: std::io::Error) -> Self {
118        Error::Io(e)
119    }
120}
121
122/// An opaque filesystem-watch backend failure — the watcher couldn't start, or
123/// couldn't register/deregister a watched path.
124///
125/// It wraps the crate's **private** watch backend so a consumer can *classify*
126/// and *source-chain* the failure without naming — or depending on — that
127/// third-party crate:
128///
129/// - [`is_path_not_found`](Self::is_path_not_found) — the watched `.git`/`.jj`
130///   directory does not exist (removed, or never present);
131/// - [`is_watch_limit`](Self::is_watch_limit) — the OS watch-descriptor limit was
132///   reached (e.g. Linux inotify's `max_user_watches`);
133/// - [`io_error`](Self::io_error) — the raw [`std::io::Error`] when the backend
134///   failure was an I/O one (also reachable via
135///   [`std::error::Error::source`]);
136/// - [`paths`](Self::paths) — the paths the backend blamed, if any.
137///
138/// Because the backend type never appears in a public signature, a backend major
139/// bump is an internal change here — not a breaking one downstream. Obtain one
140/// from [`Error::watch_error`] or by matching [`Error::Notify`].
141#[derive(Debug)]
142pub struct WatchError(notify::Error);
143
144impl WatchError {
145    /// The watched path does not exist — e.g. the `.git`/`.jj` state directory
146    /// was removed (or never existed), so the OS watch can't be registered until
147    /// the path is present.
148    pub fn is_path_not_found(&self) -> bool {
149        matches!(self.0.kind, notify::ErrorKind::PathNotFound)
150    }
151
152    /// The OS watch-descriptor limit was reached (e.g. Linux inotify's
153    /// `max_user_watches`) — the watch can't be registered until the limit is
154    /// raised or other watches are released. Best-effort / platform-dependent.
155    pub fn is_watch_limit(&self) -> bool {
156        matches!(self.0.kind, notify::ErrorKind::MaxFilesWatch)
157    }
158
159    /// The underlying [`std::io::Error`] when the backend failure was an I/O
160    /// error (`None` otherwise) — inspect its [`kind`](std::io::Error::kind)
161    /// without walking the [`source`](std::error::Error::source) chain.
162    pub fn io_error(&self) -> Option<&std::io::Error> {
163        match &self.0.kind {
164            notify::ErrorKind::Io(e) => Some(e),
165            _ => None,
166        }
167    }
168
169    /// The paths the backend associated with this failure — empty for a general,
170    /// path-less failure.
171    pub fn paths(&self) -> &[PathBuf] {
172        &self.0.paths
173    }
174}
175
176impl std::fmt::Display for WatchError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        std::fmt::Display::fmt(&self.0, f)
179    }
180}
181
182impl std::error::Error for WatchError {
183    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
184        // The backend only overrides the deprecated `cause()`, so re-expose the
185        // underlying io error via the modern `source()` for a caller walking the
186        // chain (`Error` -> `WatchError` -> `io::Error`).
187        match &self.0.kind {
188            notify::ErrorKind::Io(e) => Some(e),
189            _ => None,
190        }
191    }
192}
193
194/// `Result` specialised to the watcher [`Error`].
195pub type Result<T> = std::result::Result<T, Error>;
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    /// The classifiers delegate through the `Vcs(vcs_core::Error)` layer and the
202    /// accessor flattens the two-level nesting; non-VCS errors are inert.
203    #[test]
204    fn classifiers_and_accessor_reach_through_the_vcs_layer() {
205        // A transient io/spawn hiccup from the underlying vcs-core query.
206        let transient = Error::Vcs(vcs_core::Error::Vcs(processkit::Error::spawn(
207            "git",
208            std::io::Error::from(std::io::ErrorKind::Interrupted),
209        )));
210        assert!(transient.is_transient(), "interrupted spawn is transient");
211        assert!(!transient.is_not_found());
212        assert!(
213            transient.processkit_error().is_some(),
214            "reaches the inner error"
215        );
216        assert!(
217            transient.watch_error().is_none(),
218            "a vcs-core error is not a watch error"
219        );
220
221        // The VCS binary wasn't found (setup problem), not transient.
222        let missing = Error::Vcs(vcs_core::Error::Vcs(processkit::Error::not_found(
223            "jj", None,
224        )));
225        assert!(missing.is_not_found(), "missing binary is not-found");
226        assert!(!missing.is_transient());
227        assert!(missing.processkit_error().is_some());
228
229        // A generic filesystem (`Io`) failure is neither, and carries no process error.
230        let io = Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
231        assert!(!io.is_transient() && !io.is_not_found());
232        assert!(
233            io.processkit_error().is_none(),
234            "no subprocess behind an Io error"
235        );
236
237        // ...but a baseline-snapshot timeout (`Io` `TimedOut`, R4) IS transient — a
238        // wedged repo may un-wedge, so `build()` is worth retrying.
239        let baseline_timeout = Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
240        assert!(
241            baseline_timeout.is_transient(),
242            "a baseline TimedOut is transient (retryable)"
243        );
244    }
245
246    /// The opaque `WatchError` classifies each backend failure kind and
247    /// source-chains its io cause — all without re-exposing the backend type.
248    #[test]
249    fn watch_error_classifies_backend_kinds() {
250        // PathNotFound: the watched state dir is gone. `paths` carries the blame.
251        let e: Error = notify::Error::path_not_found()
252            .add_path(PathBuf::from("/repo/.git"))
253            .into();
254        let w = e
255            .watch_error()
256            .expect("a Notify error exposes its WatchError");
257        assert!(w.is_path_not_found());
258        assert!(!w.is_watch_limit());
259        assert!(w.io_error().is_none());
260        assert_eq!(w.paths(), [PathBuf::from("/repo/.git")]);
261        // No io cause for a path-not-found, and the enum classifiers stay inert.
262        assert!(std::error::Error::source(w).is_none());
263        assert!(!e.is_transient() && !e.is_not_found());
264        assert!(e.processkit_error().is_none());
265
266        // MaxFilesWatch: the inotify / descriptor limit.
267        let limit: Error = notify::Error::new(notify::ErrorKind::MaxFilesWatch).into();
268        let w = limit.watch_error().expect("WatchError");
269        assert!(w.is_watch_limit() && !w.is_path_not_found());
270
271        // Io: the raw error is reachable directly and via `source()`.
272        let io: Error =
273            notify::Error::io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)).into();
274        let w = io.watch_error().expect("WatchError");
275        assert_eq!(
276            w.io_error().map(|e| e.kind()),
277            Some(std::io::ErrorKind::PermissionDenied)
278        );
279        let src = std::error::Error::source(w).expect("io cause is source-chained");
280        assert!(src.downcast_ref::<std::io::Error>().is_some());
281    }
282
283    /// The top-level `Error` source chain reaches the io cause *through* the
284    /// opaque wrapper: `Error` -> `WatchError` -> `io::Error`.
285    #[test]
286    fn top_level_source_chain_reaches_io_through_watch_error() {
287        let e: Error = notify::Error::io(std::io::Error::from(std::io::ErrorKind::NotFound)).into();
288        let first = std::error::Error::source(&e).expect("WatchError is the first source");
289        assert!(
290            first.downcast_ref::<WatchError>().is_some(),
291            "the opaque wrapper is the immediate source"
292        );
293        let second = first.source().expect("io::Error is the next link");
294        assert!(second.downcast_ref::<std::io::Error>().is_some());
295    }
296}