Skip to main content

wt/
error.rs

1//! Typed error type for the `wt` library.
2//!
3//! Library APIs return [`Error`]; the binary maps it to a process exit code via
4//! [`Error::exit_code`]. Exit codes follow the spec (§12): `0` success, `1`
5//! user/operation error, `2` usage/argument error, `3` ambiguous query or
6//! nothing selected.
7
8/// A convenient `Result` alias for `wt` library operations.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Errors produced by `wt` library operations.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    /// The current directory is not inside a Git repository.
15    #[error("not in a git repository")]
16    NotInRepo,
17
18    /// There is no current worktree (e.g. a bare repository) but the command
19    /// requires one.
20    #[error("no current worktree (bare repository); pass a query")]
21    NoCurrentWorktree,
22
23    /// A query resolved to more than one worktree (spec exit code `3`).
24    #[error("query {query:?} is ambiguous ({} candidates)", candidates.len())]
25    Ambiguous {
26        /// The query string that was ambiguous.
27        query: String,
28        /// Human-readable identifiers of the matching worktrees.
29        candidates: Vec<String>,
30    },
31
32    /// A query matched no worktree (spec exit code `1`).
33    #[error("no worktree matches {query:?}")]
34    NotFound {
35        /// The query string that matched nothing.
36        query: String,
37    },
38
39    /// Nothing was selected, e.g. a cancelled picker (spec exit code `3`).
40    #[error("nothing selected")]
41    NothingSelected,
42
43    /// A usage or argument error (spec exit code `2`).
44    #[error("{0}")]
45    Usage(String),
46
47    /// A configuration error, naming the file, key, and reason.
48    #[error("{file}: {key}: {reason}")]
49    Config {
50        /// Path (or label) of the offending config file.
51        file: String,
52        /// The offending key.
53        key: String,
54        /// Why it was rejected.
55        reason: String,
56    },
57
58    /// A subprocess (`git`, `gh`, or a code agent) failed; `stderr` is surfaced
59    /// verbatim.
60    #[error("{program} failed: {stderr}")]
61    Subprocess {
62        /// The program that failed (e.g. `git`, `gh`, `claude`).
63        program: String,
64        /// Captured standard error, verbatim.
65        stderr: String,
66    },
67
68    /// The `gh` CLI is missing or unauthenticated.
69    #[error("{0}")]
70    GhUnavailable(String),
71
72    /// No code-agent CLI is available (missing binary or failed to launch).
73    #[error("{0}")]
74    AgentUnavailable(String),
75
76    /// A code-agent run exceeded its deadline and was killed. Generation is
77    /// always best-effort, so callers are expected to fall back rather than
78    /// surface this to the user as a failure.
79    #[error("{binary} did not respond within {seconds}s")]
80    AgentTimeout {
81        /// The agent binary that was killed.
82        binary: String,
83        /// The deadline it exceeded, in seconds.
84        seconds: u64,
85    },
86
87    /// The repository's `wt.schema` was written by a newer `wt` (or embedder)
88    /// than this one; reading it could silently misinterpret metadata, so the
89    /// operation is refused (issue #99).
90    #[error(
91        "repository metadata schema (wt.schema = {found}) is newer than this wt supports \
92         (up to {supported}); upgrade wt to work with this repository"
93    )]
94    SchemaTooNew {
95        /// The `wt.schema` value recorded in the repository.
96        found: u64,
97        /// The highest schema this build understands.
98        supported: u64,
99    },
100
101    /// The repository's advisory mutation lock could not be acquired (issue
102    /// #99) — usually another `wt` (or embedder) operation is in flight.
103    #[error("could not acquire the wt repository lock at {path}: {reason}")]
104    LockUnavailable {
105        /// The lock file path.
106        path: String,
107        /// Why acquisition failed (e.g. a timeout while another holder ran).
108        reason: String,
109    },
110
111    /// Worktree removal was blocked by the dirty/unpushed safety guards
112    /// (spec §10/§12); force-removal overrides them.
113    #[error("worktree {}; use --force to remove anyway", guard_reasons(*dirty, *unpushed))]
114    RemoveGuarded {
115        /// Modified/staged tracked files (or untracked files when the config
116        /// counts them).
117        dirty: bool,
118        /// Local commits ahead of upstream, or no upstream configured.
119        unpushed: bool,
120    },
121
122    /// An operation failed for the reason described by the message.
123    #[error("{0}")]
124    Operation(String),
125
126    /// An underlying I/O error.
127    #[error("{0}")]
128    Io(#[from] std::io::Error),
129
130    /// A JSON serialization or deserialization error.
131    #[error("json error: {0}")]
132    Json(#[from] serde_json::Error),
133}
134
135/// Renders the blocking guard reasons for [`Error::RemoveGuarded`], matching
136/// the CLI's historical wording.
137fn guard_reasons(dirty: bool, unpushed: bool) -> String {
138    let mut reasons = Vec::new();
139    if dirty {
140        reasons.push("has uncommitted changes");
141    }
142    if unpushed {
143        reasons.push("has unpushed work");
144    }
145    reasons.join(" and ")
146}
147
148impl Error {
149    /// The process exit code this error maps to (spec §12): `2` for usage
150    /// errors, `3` for ambiguous queries or nothing selected, `1` otherwise.
151    pub fn exit_code(&self) -> u8 {
152        match self {
153            Error::Usage(_) => 2,
154            Error::Ambiguous { .. } | Error::NothingSelected => 3,
155            _ => 1,
156        }
157    }
158
159    /// Builds an [`Error::Operation`] from anything string-like.
160    pub fn operation(message: impl Into<String>) -> Self {
161        Error::Operation(message.into())
162    }
163
164    /// Builds an [`Error::Usage`] from anything string-like.
165    pub fn usage(message: impl Into<String>) -> Self {
166        Error::Usage(message.into())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn exit_codes_match_spec() {
176        assert_eq!(Error::usage("x").exit_code(), 2);
177        assert_eq!(
178            Error::Ambiguous {
179                query: "f".into(),
180                candidates: vec!["a".into(), "b".into()],
181            }
182            .exit_code(),
183            3
184        );
185        assert_eq!(Error::NothingSelected.exit_code(), 3);
186        assert_eq!(Error::NotFound { query: "f".into() }.exit_code(), 1);
187        assert_eq!(Error::NotInRepo.exit_code(), 1);
188        assert_eq!(Error::NoCurrentWorktree.exit_code(), 1);
189        assert_eq!(
190            Error::Config {
191                file: "c".into(),
192                key: "k".into(),
193                reason: "r".into(),
194            }
195            .exit_code(),
196            1
197        );
198        assert_eq!(
199            Error::Subprocess {
200                program: "git".into(),
201                stderr: "boom".into(),
202            }
203            .exit_code(),
204            1
205        );
206        assert_eq!(
207            Error::RemoveGuarded {
208                dirty: true,
209                unpushed: false,
210            }
211            .exit_code(),
212            1
213        );
214        assert_eq!(Error::GhUnavailable("gh".into()).exit_code(), 1);
215        assert_eq!(Error::AgentUnavailable("a".into()).exit_code(), 1);
216        assert_eq!(Error::operation("op").exit_code(), 1);
217        assert_eq!(Error::from(std::io::Error::other("io")).exit_code(), 1);
218        let json_err = serde_json::from_str::<i32>("nope").unwrap_err();
219        assert_eq!(Error::from(json_err).exit_code(), 1);
220    }
221
222    #[test]
223    fn display_messages_are_descriptive() {
224        assert!(Error::NotInRepo.to_string().contains("git repository"));
225        assert!(
226            Error::NoCurrentWorktree
227                .to_string()
228                .contains("no current worktree")
229        );
230        assert!(
231            Error::Ambiguous {
232                query: "feat".into(),
233                candidates: vec!["a".into(), "b".into()],
234            }
235            .to_string()
236            .contains("ambiguous")
237        );
238        assert!(
239            Error::NotFound { query: "x".into() }
240                .to_string()
241                .contains("no worktree")
242        );
243        assert_eq!(Error::NothingSelected.to_string(), "nothing selected");
244        assert_eq!(Error::usage("oops").to_string(), "oops");
245        assert_eq!(
246            Error::Config {
247                file: "f".into(),
248                key: "k".into(),
249                reason: "r".into(),
250            }
251            .to_string(),
252            "f: k: r"
253        );
254        assert_eq!(
255            Error::Subprocess {
256                program: "gh".into(),
257                stderr: "no auth".into(),
258            }
259            .to_string(),
260            "gh failed: no auth"
261        );
262        assert_eq!(
263            Error::RemoveGuarded {
264                dirty: true,
265                unpushed: true,
266            }
267            .to_string(),
268            "worktree has uncommitted changes and has unpushed work; use --force to remove anyway"
269        );
270        assert_eq!(
271            Error::RemoveGuarded {
272                dirty: false,
273                unpushed: true,
274            }
275            .to_string(),
276            "worktree has unpushed work; use --force to remove anyway"
277        );
278        assert_eq!(Error::GhUnavailable("nope".into()).to_string(), "nope");
279        assert_eq!(Error::AgentUnavailable("nope".into()).to_string(), "nope");
280        assert_eq!(Error::operation("op").to_string(), "op");
281        assert_eq!(Error::from(std::io::Error::other("io")).to_string(), "io");
282    }
283}