Skip to main content

vcs_core/
error.rs

1//! The facade's error type: a thin wrapper that adds repo-detection failures on
2//! top of the underlying [`processkit::Error`] the per-tool clients return.
3//!
4//! The [`Error::Vcs`] variant carries a [`processkit::Error`] verbatim — re-exported
5//! at the crate root (`vcs_core::processkit`) so you can match it without a direct
6//! `processkit` dependency. Prefer the `is_*` classifiers ([`is_merge_conflict`](Error::is_merge_conflict)
7//! / [`is_nothing_to_commit`](Error::is_nothing_to_commit) /
8//! [`is_transient_fetch_error`](Error::is_transient_fetch_error) /
9//! [`is_transient`](Error::is_transient) / [`is_not_found`](Error::is_not_found))
10//! to branch on intent rather than matching the wrapped error's internals.
11
12use std::path::PathBuf;
13
14/// An error from a [`Repo`](crate::Repo) operation.
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum Error {
18    /// [`Repo::open`](crate::Repo::open) found no `.git`/`.jj` from the start dir
19    /// up to the filesystem root.
20    NotARepository(PathBuf),
21    /// A worktree/workspace lookup by path matched no attached worktree.
22    WorktreeNotFound(PathBuf),
23    /// A filesystem operation failed (e.g. removing a workspace directory).
24    Io(std::io::Error),
25    /// An underlying `vcs-git` / `vcs-jj` (i.e. `processkit`) error.
26    Vcs(processkit::Error),
27}
28
29impl Error {
30    /// Whether this wraps a merge/rebase **conflict** from the backend — so a
31    /// caller can branch on "conflict, resolve it" vs. a hard failure without
32    /// matching on [`processkit::Error`] internals. (Recognises git's conflict
33    /// markers; jj surfaces conflicts as state, not errors — see
34    /// [`Repo::in_progress_state`](crate::Repo::in_progress_state).)
35    ///
36    /// Named to match the wrapper classifiers
37    /// ([`vcs_cli_support::is_merge_conflict`]) — one name per concept across the
38    /// workspace.
39    pub fn is_merge_conflict(&self) -> bool {
40        matches!(self, Error::Vcs(e) if vcs_cli_support::is_merge_conflict(e))
41    }
42
43    /// Whether this is a benign "nothing to commit" — an empty commit attempt the
44    /// caller likely wants to treat as a no-op.
45    pub fn is_nothing_to_commit(&self) -> bool {
46        matches!(self, Error::Vcs(e) if vcs_cli_support::is_nothing_to_commit(e))
47    }
48
49    /// Whether this is a **transient** fetch/network failure worth retrying — DNS, a
50    /// dropped connection, a fast blip. A **timeout is not** transient (it already
51    /// spent the full deadline; retrying would multiply the wall-clock — see
52    /// [`vcs_cli_support::is_transient_fetch_error`]). The underlying clients already
53    /// retry their own fetches; this is for retrying higher-level flows.
54    pub fn is_transient_fetch_error(&self) -> bool {
55        matches!(self, Error::Vcs(e) if vcs_cli_support::is_transient_fetch_error(e))
56    }
57
58    /// Whether the underlying error is a **transient io/spawn** failure
59    /// (interrupted / would-block / resource-busy) — delegates to
60    /// [`processkit::Error::is_transient`]. Narrower than
61    /// [`is_transient_fetch_error`](Error::is_transient_fetch_error) (which also
62    /// treats the network markers as retryable — but not a timeout); use this to retry
63    /// *any* operation past a momentary io hiccup. The facade's own
64    /// [`Io`](Error::Io)/[`NotARepository`](Error::NotARepository)/
65    /// [`WorktreeNotFound`](Error::WorktreeNotFound) variants are never transient.
66    pub fn is_transient(&self) -> bool {
67        matches!(self, Error::Vcs(e) if e.is_transient())
68    }
69
70    /// Whether the underlying CLI binary (`git`/`jj`) **wasn't found** — a setup
71    /// problem (the tool isn't installed or isn't on `PATH`), not a repository or
72    /// usage error. Delegates to [`processkit::Error::is_not_found`]; lets a caller
73    /// surface a "please install git/jj" hint instead of a raw spawn failure.
74    pub fn is_not_found(&self) -> bool {
75        matches!(self, Error::Vcs(e) if e.is_not_found())
76    }
77
78    /// Whether this is an **input rejection** — a value the facade refused *before*
79    /// spawning, because it was a bad argument: a flag-like/empty/NUL-containing
80    /// value in a guarded positional slot (via the wrapper guards), or a facade-level
81    /// precondition on the arguments (an empty file set for `commit_paths`, removing
82    /// the main workspace). This is a **caller bug**, distinct from a real IO or
83    /// backend failure — a language binding maps it to a `ValueError`. Completes the
84    /// `is_*` classifier family alongside [`is_not_found`](Error::is_not_found).
85    pub fn is_invalid_input(&self) -> bool {
86        match self {
87            Error::Io(e) => e.kind() == std::io::ErrorKind::InvalidInput,
88            Error::Vcs(e) => vcs_cli_support::is_invalid_input(e),
89            _ => false,
90        }
91    }
92
93    /// Whether a **resource the operation named doesn't exist** — currently a
94    /// worktree/workspace lookup by path that matched no attached worktree
95    /// ([`WorktreeNotFound`](Error::WorktreeNotFound)). Distinct from
96    /// [`is_not_found`](Error::is_not_found), which means the `git`/`jj` **binary**
97    /// wasn't found (a setup problem), and from [`is_invalid_input`](Error::is_invalid_input)
98    /// (a bad argument). A binding maps this to a `NotFoundError`.
99    ///
100    /// Note the backend asymmetry: only the **jj** backend raises the typed
101    /// `WorktreeNotFound`; git's missing-worktree removal surfaces as a generic
102    /// backend `Exit`, which this does not classify. (Likewise the main-workspace
103    /// refusal that [`is_invalid_input`](Error::is_invalid_input) recognizes is a
104    /// typed error only on jj.)
105    pub fn is_resource_not_found(&self) -> bool {
106        matches!(self, Error::WorktreeNotFound(_))
107    }
108}
109
110impl std::fmt::Display for Error {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            Error::NotARepository(p) => {
114                write!(
115                    f,
116                    "no git or jj repository found at or above {}",
117                    p.display()
118                )
119            }
120            Error::WorktreeNotFound(p) => {
121                write!(f, "no worktree found at {}", p.display())
122            }
123            Error::Io(e) => write!(f, "{e}"),
124            Error::Vcs(e) => write!(f, "{e}"),
125        }
126    }
127}
128
129impl std::error::Error for Error {
130    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
131        match self {
132            Error::Io(e) => Some(e),
133            Error::Vcs(e) => Some(e),
134            _ => None,
135        }
136    }
137}
138
139impl From<std::io::Error> for Error {
140    fn from(e: std::io::Error) -> Self {
141        Error::Io(e)
142    }
143}
144
145impl From<processkit::Error> for Error {
146    fn from(e: processkit::Error) -> Self {
147        Error::Vcs(e)
148    }
149}
150
151/// `Result` specialised to the facade [`Error`].
152pub type Result<T> = std::result::Result<T, Error>;
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn is_transient_delegates_to_processkit_and_excludes_facade_variants() {
160        // An interrupted spawn is a transient io failure.
161        let interrupted = Error::Vcs(processkit::Error::Spawn {
162            program: "git".into(),
163            source: std::io::Error::from(std::io::ErrorKind::Interrupted),
164        });
165        assert!(interrupted.is_transient());
166        // A missing binary is NOT transient (retrying won't install it).
167        let missing = Error::Vcs(processkit::Error::Spawn {
168            program: "git".into(),
169            source: std::io::Error::from(std::io::ErrorKind::NotFound),
170        });
171        assert!(!missing.is_transient());
172        // The facade's own io/detection variants are never transient.
173        assert!(!Error::Io(std::io::Error::from(std::io::ErrorKind::Interrupted)).is_transient());
174        assert!(!Error::NotARepository("/x".into()).is_transient());
175    }
176
177    #[test]
178    fn is_not_found_only_for_a_missing_binary() {
179        let not_found = Error::Vcs(processkit::Error::NotFound {
180            program: "jj".into(),
181            searched: None,
182        });
183        assert!(not_found.is_not_found());
184        // An ordinary non-zero exit is not a "binary not found".
185        let exit = Error::Vcs(processkit::Error::Exit {
186            program: "git".into(),
187            code: 1,
188            stdout: String::new(),
189            stderr: "fatal: not a git repository".into(),
190        });
191        assert!(!exit.is_not_found());
192        assert!(!Error::NotARepository("/x".into()).is_not_found());
193    }
194
195    #[test]
196    fn is_invalid_input_for_guard_rejections_and_facade_input_errors() {
197        // A wrapper guard rejection (flag-like positional) surfaces as invalid input.
198        let guarded = Error::Vcs(processkit::Error::Spawn {
199            program: "git".into(),
200            source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "flag-like"),
201        });
202        assert!(guarded.is_invalid_input());
203        // The facade's own `Io(InvalidInput)` guard (e.g. an empty commit set) too.
204        assert!(
205            Error::Io(std::io::Error::from(std::io::ErrorKind::InvalidInput)).is_invalid_input()
206        );
207        // A real spawn failure, a detection error, and a generic io error are NOT.
208        assert!(
209            !Error::Vcs(processkit::Error::Spawn {
210                program: "git".into(),
211                source: std::io::Error::from(std::io::ErrorKind::NotFound),
212            })
213            .is_invalid_input()
214        );
215        assert!(!Error::NotARepository("/x".into()).is_invalid_input());
216        assert!(!Error::Io(std::io::Error::other("disk full")).is_invalid_input());
217    }
218
219    #[test]
220    fn is_resource_not_found_only_for_a_worktree_lookup() {
221        assert!(Error::WorktreeNotFound("/wt".into()).is_resource_not_found());
222        // The *binary* missing is a different classifier (is_not_found), and a bad
223        // repo path is neither.
224        let missing_bin = Error::Vcs(processkit::Error::NotFound {
225            program: "jj".into(),
226            searched: None,
227        });
228        assert!(missing_bin.is_not_found() && !missing_bin.is_resource_not_found());
229        assert!(!Error::NotARepository("/x".into()).is_resource_not_found());
230    }
231}