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}