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::discover`](crate::Repo::discover) found no `.git`/`.jj` from the
19    /// start dir up to the filesystem root, or [`Repo::open`](crate::Repo::open)
20    /// found no `.git`/`.jj` marker in the exact directory it was given.
21    NotARepository(PathBuf),
22    /// [`Repo::discover`](crate::Repo::discover) walked up to a **bare** git
23    /// repository (created with `git init --bare`, or an equivalent bare clone)
24    /// — a directory holding `HEAD`/`config`/`objects`/`refs` directly, with no
25    /// `.git` subdirectory and no worktree. This is distinct from
26    /// [`NotARepository`](Error::NotARepository): a bare repository *is* a
27    /// valid git repository, just one this facade doesn't drive (it has no
28    /// working tree for the CLI wrappers to operate against). See
29    /// <https://github.com/ZelAnton/vcs-toolkit-rs/issues/6>.
30    BareRepository(PathBuf),
31    /// A worktree/workspace lookup by path matched no attached worktree.
32    WorktreeNotFound(PathBuf),
33    /// A filesystem operation failed (e.g. removing a workspace directory).
34    Io(std::io::Error),
35    /// An underlying `vcs-git` / `vcs-jj` (i.e. `processkit`) error.
36    Vcs(processkit::Error),
37    /// A concurrency-safe op-log rollback could not restore the repository to its
38    /// captured pre-operation state: the `op restore` failed, or a **concurrent** jj
39    /// process advanced the operation log so reverting would have clobbered its work
40    /// (see [`vcs_jj::Rollback`]). Raised by
41    /// [`Repo::try_merge`](crate::Repo::try_merge) on the jj backend when its
42    /// trial-merge rollback cannot complete cleanly — the trial merge may remain
43    /// materialized, so the probe result would be untrustworthy. The structured
44    /// [`vcs_jj::Rollback`] carries which case it was (and, for a failed restore, the
45    /// underlying cause).
46    Rollback(vcs_jj::Rollback),
47    /// The requested action has no meaningful mapping for the repository's current
48    /// in-progress state, so it is refused **explicitly** rather than performed as a
49    /// misleading success. Currently raised by
50    /// [`Repo::continue_in_progress`](crate::Repo::continue_in_progress) during a
51    /// `git bisect`: a bisect advances by marking commits good/bad, not by a
52    /// `--continue` step, so "continue" cannot be honoured. Carries a short message
53    /// naming the situation. Classified by
54    /// [`is_unsupported`](Error::is_unsupported); a language binding maps it to an
55    /// `unsupported`/`ValueError`-style error.
56    Unsupported(String),
57}
58
59impl Error {
60    /// Whether this wraps a merge/rebase **conflict** from the backend — so a
61    /// caller can branch on "conflict, resolve it" vs. a hard failure without
62    /// matching on [`processkit::Error`] internals. (Recognises git's conflict
63    /// markers; jj surfaces conflicts as state, not errors — see
64    /// [`Repo::in_progress_state`](crate::Repo::in_progress_state).)
65    ///
66    /// Named to match the wrapper classifiers
67    /// ([`vcs_cli_support::is_merge_conflict`]) — one name per concept across the
68    /// workspace.
69    pub fn is_merge_conflict(&self) -> bool {
70        matches!(self, Error::Vcs(e) if vcs_cli_support::is_merge_conflict(e))
71    }
72
73    /// Whether this is a benign "nothing to commit" — an empty commit attempt the
74    /// caller likely wants to treat as a no-op.
75    pub fn is_nothing_to_commit(&self) -> bool {
76        matches!(self, Error::Vcs(e) if vcs_cli_support::is_nothing_to_commit(e))
77    }
78
79    /// Whether this is a **transient** fetch/network failure worth retrying — DNS, a
80    /// dropped connection, a fast blip. A **timeout is not** transient (it already
81    /// spent the full deadline; retrying would multiply the wall-clock — see
82    /// [`vcs_cli_support::is_transient_fetch_error`]). The underlying clients already
83    /// retry their own fetches; this is for retrying higher-level flows.
84    pub fn is_transient_fetch_error(&self) -> bool {
85        matches!(self, Error::Vcs(e) if vcs_cli_support::is_transient_fetch_error(e))
86    }
87
88    /// Whether the underlying error is a **transient io/spawn** failure
89    /// (interrupted / would-block / resource-busy) — delegates to
90    /// [`processkit::Error::is_transient`]. Narrower than
91    /// [`is_transient_fetch_error`](Error::is_transient_fetch_error) (which also
92    /// treats the network markers as retryable — but not a timeout); use this to retry
93    /// *any* operation past a momentary io hiccup. The facade's own
94    /// [`Io`](Error::Io)/[`NotARepository`](Error::NotARepository)/
95    /// [`BareRepository`](Error::BareRepository)/
96    /// [`WorktreeNotFound`](Error::WorktreeNotFound) variants are never transient.
97    pub fn is_transient(&self) -> bool {
98        matches!(self, Error::Vcs(e) if e.is_transient())
99    }
100
101    /// Whether the underlying CLI binary (`git`/`jj`) **wasn't found** — a setup
102    /// problem (the tool isn't installed or isn't on `PATH`), not a repository or
103    /// usage error. Delegates to [`processkit::Error::is_not_found`]; lets a caller
104    /// surface a "please install git/jj" hint instead of a raw spawn failure.
105    pub fn is_not_found(&self) -> bool {
106        matches!(self, Error::Vcs(e) if e.is_not_found())
107    }
108
109    /// Whether this is an **input rejection** — a value the facade refused *before*
110    /// spawning, because it was a bad argument: a flag-like/empty/NUL-containing
111    /// value in a guarded positional slot (via the wrapper guards), or a facade-level
112    /// precondition on the arguments (an empty file set for `commit_paths`, removing
113    /// the main workspace). This is a **caller bug**, distinct from a real IO or
114    /// backend failure — a language binding maps it to a `ValueError`. Completes the
115    /// `is_*` classifier family alongside [`is_not_found`](Error::is_not_found).
116    pub fn is_invalid_input(&self) -> bool {
117        match self {
118            Error::Io(e) => e.kind() == std::io::ErrorKind::InvalidInput,
119            Error::Vcs(e) => vcs_cli_support::is_invalid_input(e),
120            _ => false,
121        }
122    }
123
124    /// Whether a **resource the operation named doesn't exist** — currently a
125    /// worktree/workspace lookup by path that matched no attached worktree
126    /// ([`WorktreeNotFound`](Error::WorktreeNotFound)). Distinct from
127    /// [`is_not_found`](Error::is_not_found), which means the `git`/`jj` **binary**
128    /// wasn't found (a setup problem), and from [`is_invalid_input`](Error::is_invalid_input)
129    /// (a bad argument). A binding maps this to a `NotFoundError`.
130    ///
131    /// Note the backend asymmetry: only the **jj** backend raises the typed
132    /// `WorktreeNotFound`; git's missing-worktree removal surfaces as a generic
133    /// backend `Exit`, which this does not classify. (Likewise the main-workspace
134    /// refusal that [`is_invalid_input`](Error::is_invalid_input) recognizes is a
135    /// typed error only on jj.)
136    pub fn is_resource_not_found(&self) -> bool {
137        matches!(self, Error::WorktreeNotFound(_))
138    }
139
140    /// Whether this is an [`Unsupported`](Error::Unsupported) action — the caller
141    /// asked for something the repository's current in-progress state cannot
142    /// honour (e.g. `continue_in_progress` during a `git bisect`). Distinct from
143    /// [`is_invalid_input`](Error::is_invalid_input) (a *bad argument*): the
144    /// argument was fine, the *state* just has no such step. Mirrors
145    /// `vcs_forge::Error::is_unsupported`, so the two facades name the concept the
146    /// same way for a language binding.
147    pub fn is_unsupported(&self) -> bool {
148        matches!(self, Error::Unsupported(_))
149    }
150}
151
152impl std::fmt::Display for Error {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Error::NotARepository(p) => {
156                // Deliberately doesn't say "at or above": `Repo::open` returns this
157                // for a strict check of exactly `p` (no walking up), while
158                // `Repo::discover` returns it after walking up from `p` and finding
159                // nothing — a single wording that's accurate for both callers.
160                write!(f, "no git or jj repository found at {}", p.display())
161            }
162            Error::BareRepository(p) => {
163                write!(f, "bare git repositories are unsupported ({})", p.display())
164            }
165            Error::WorktreeNotFound(p) => {
166                write!(f, "no worktree found at {}", p.display())
167            }
168            Error::Io(e) => write!(f, "{e}"),
169            Error::Vcs(e) => write!(f, "{e}"),
170            Error::Rollback(r) => {
171                write!(f, "operation rollback did not complete cleanly: {r}")
172            }
173            Error::Unsupported(what) => write!(f, "unsupported operation: {what}"),
174        }
175    }
176}
177
178impl std::error::Error for Error {
179    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
180        match self {
181            Error::Io(e) => Some(e),
182            Error::Vcs(e) => Some(e),
183            // A failed restore carries the underlying cause; a divergence-skip has
184            // no wrapped error to chain.
185            Error::Rollback(r) => r.failure().map(|e| e as &(dyn std::error::Error + 'static)),
186            _ => None,
187        }
188    }
189}
190
191impl From<std::io::Error> for Error {
192    fn from(e: std::io::Error) -> Self {
193        Error::Io(e)
194    }
195}
196
197impl From<processkit::Error> for Error {
198    fn from(e: processkit::Error) -> Self {
199        Error::Vcs(e)
200    }
201}
202
203/// `Result` specialised to the facade [`Error`].
204pub type Result<T> = std::result::Result<T, Error>;
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn is_transient_delegates_to_processkit_and_excludes_facade_variants() {
212        // An interrupted spawn is a transient io failure.
213        let interrupted = Error::Vcs(processkit::Error::spawn(
214            "git",
215            std::io::Error::from(std::io::ErrorKind::Interrupted),
216        ));
217        assert!(interrupted.is_transient());
218        // A missing binary is NOT transient (retrying won't install it).
219        let missing = Error::Vcs(processkit::Error::spawn(
220            "git",
221            std::io::Error::from(std::io::ErrorKind::NotFound),
222        ));
223        assert!(!missing.is_transient());
224        // The facade's own io/detection variants are never transient.
225        assert!(!Error::Io(std::io::Error::from(std::io::ErrorKind::Interrupted)).is_transient());
226        assert!(!Error::NotARepository("/x".into()).is_transient());
227    }
228
229    #[test]
230    fn is_not_found_only_for_a_missing_binary() {
231        let not_found = Error::Vcs(processkit::Error::not_found("jj", None));
232        assert!(not_found.is_not_found());
233        // An ordinary non-zero exit is not a "binary not found".
234        let exit = Error::Vcs(processkit::Error::exit(
235            "git",
236            1,
237            "",
238            "fatal: not a git repository",
239        ));
240        assert!(!exit.is_not_found());
241        assert!(!Error::NotARepository("/x".into()).is_not_found());
242    }
243
244    #[test]
245    fn is_invalid_input_for_guard_rejections_and_facade_input_errors() {
246        // A wrapper guard rejection (flag-like positional) surfaces as invalid input.
247        let guarded = Error::Vcs(processkit::Error::spawn(
248            "git",
249            std::io::Error::new(std::io::ErrorKind::InvalidInput, "flag-like"),
250        ));
251        assert!(guarded.is_invalid_input());
252        // The facade's own `Io(InvalidInput)` guard (e.g. an empty commit set) too.
253        assert!(
254            Error::Io(std::io::Error::from(std::io::ErrorKind::InvalidInput)).is_invalid_input()
255        );
256        // A real spawn failure, a detection error, and a generic io error are NOT.
257        assert!(
258            !Error::Vcs(processkit::Error::spawn(
259                "git",
260                std::io::Error::from(std::io::ErrorKind::NotFound),
261            ))
262            .is_invalid_input()
263        );
264        assert!(!Error::NotARepository("/x".into()).is_invalid_input());
265        assert!(!Error::Io(std::io::Error::other("disk full")).is_invalid_input());
266    }
267
268    #[test]
269    fn is_unsupported_only_for_the_unsupported_variant() {
270        let unsupported = Error::Unsupported("continue during a bisect".into());
271        assert!(unsupported.is_unsupported());
272        assert!(unsupported.to_string().contains("bisect"));
273        // Not conflated with a bad-argument rejection or any other variant.
274        assert!(!unsupported.is_invalid_input());
275        assert!(
276            !Error::Io(std::io::Error::from(std::io::ErrorKind::InvalidInput)).is_unsupported()
277        );
278        assert!(!Error::NotARepository("/x".into()).is_unsupported());
279    }
280
281    #[test]
282    fn is_resource_not_found_only_for_a_worktree_lookup() {
283        assert!(Error::WorktreeNotFound("/wt".into()).is_resource_not_found());
284        // The *binary* missing is a different classifier (is_not_found), and a bad
285        // repo path is neither.
286        let missing_bin = Error::Vcs(processkit::Error::not_found("jj", None));
287        assert!(missing_bin.is_not_found() && !missing_bin.is_resource_not_found());
288        assert!(!Error::NotARepository("/x".into()).is_resource_not_found());
289    }
290}