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    /// Worktree removal was refused because the worktree is locked
123    /// (`git worktree lock`); the explicit unlock opt-in (`--locked`)
124    /// overrides it (issue #126).
125    #[error("worktree is locked{}; use --locked", lock_reason_suffix(reason.as_deref()))]
126    WorktreeLocked {
127        /// The reason recorded when the worktree was locked, if any.
128        reason: Option<String>,
129    },
130
131    /// An operation failed for the reason described by the message.
132    #[error("{0}")]
133    Operation(String),
134
135    /// An underlying I/O error.
136    #[error("{0}")]
137    Io(#[from] std::io::Error),
138
139    /// A JSON serialization or deserialization error.
140    #[error("json error: {0}")]
141    Json(#[from] serde_json::Error),
142}
143
144/// Renders the blocking guard reasons for [`Error::RemoveGuarded`], matching
145/// the CLI's historical wording.
146fn guard_reasons(dirty: bool, unpushed: bool) -> String {
147    let mut reasons = Vec::new();
148    if dirty {
149        reasons.push("has uncommitted changes");
150    }
151    if unpushed {
152        reasons.push("has unpushed work");
153    }
154    reasons.join(" and ")
155}
156
157/// Renders the ` (<reason>)` suffix for [`Error::WorktreeLocked`], matching
158/// `wt prune`'s locked-skip wording; empty when the lock has no reason.
159fn lock_reason_suffix(reason: Option<&str>) -> String {
160    reason.map(|r| format!(" ({r})")).unwrap_or_default()
161}
162
163impl Error {
164    /// The process exit code this error maps to (spec §12): `2` for usage
165    /// errors, `3` for ambiguous queries or nothing selected, `1` otherwise.
166    pub fn exit_code(&self) -> u8 {
167        match self {
168            Error::Usage(_) => 2,
169            Error::Ambiguous { .. } | Error::NothingSelected => 3,
170            _ => 1,
171        }
172    }
173
174    /// Builds an [`Error::Operation`] from anything string-like.
175    pub fn operation(message: impl Into<String>) -> Self {
176        Error::Operation(message.into())
177    }
178
179    /// Builds an [`Error::Usage`] from anything string-like.
180    pub fn usage(message: impl Into<String>) -> Self {
181        Error::Usage(message.into())
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn exit_codes_match_spec() {
191        assert_eq!(Error::usage("x").exit_code(), 2);
192        assert_eq!(
193            Error::Ambiguous {
194                query: "f".into(),
195                candidates: vec!["a".into(), "b".into()],
196            }
197            .exit_code(),
198            3
199        );
200        assert_eq!(Error::NothingSelected.exit_code(), 3);
201        assert_eq!(Error::NotFound { query: "f".into() }.exit_code(), 1);
202        assert_eq!(Error::NotInRepo.exit_code(), 1);
203        assert_eq!(Error::NoCurrentWorktree.exit_code(), 1);
204        assert_eq!(
205            Error::Config {
206                file: "c".into(),
207                key: "k".into(),
208                reason: "r".into(),
209            }
210            .exit_code(),
211            1
212        );
213        assert_eq!(
214            Error::Subprocess {
215                program: "git".into(),
216                stderr: "boom".into(),
217            }
218            .exit_code(),
219            1
220        );
221        assert_eq!(
222            Error::RemoveGuarded {
223                dirty: true,
224                unpushed: false,
225            }
226            .exit_code(),
227            1
228        );
229        assert_eq!(Error::GhUnavailable("gh".into()).exit_code(), 1);
230        assert_eq!(Error::AgentUnavailable("a".into()).exit_code(), 1);
231        assert_eq!(Error::operation("op").exit_code(), 1);
232        assert_eq!(Error::from(std::io::Error::other("io")).exit_code(), 1);
233        let json_err = serde_json::from_str::<i32>("nope").unwrap_err();
234        assert_eq!(Error::from(json_err).exit_code(), 1);
235    }
236
237    #[test]
238    fn display_messages_are_descriptive() {
239        assert!(Error::NotInRepo.to_string().contains("git repository"));
240        assert!(
241            Error::NoCurrentWorktree
242                .to_string()
243                .contains("no current worktree")
244        );
245        assert!(
246            Error::Ambiguous {
247                query: "feat".into(),
248                candidates: vec!["a".into(), "b".into()],
249            }
250            .to_string()
251            .contains("ambiguous")
252        );
253        assert!(
254            Error::NotFound { query: "x".into() }
255                .to_string()
256                .contains("no worktree")
257        );
258        assert_eq!(Error::NothingSelected.to_string(), "nothing selected");
259        assert_eq!(Error::usage("oops").to_string(), "oops");
260        assert_eq!(
261            Error::Config {
262                file: "f".into(),
263                key: "k".into(),
264                reason: "r".into(),
265            }
266            .to_string(),
267            "f: k: r"
268        );
269        assert_eq!(
270            Error::Subprocess {
271                program: "gh".into(),
272                stderr: "no auth".into(),
273            }
274            .to_string(),
275            "gh failed: no auth"
276        );
277        assert_eq!(
278            Error::RemoveGuarded {
279                dirty: true,
280                unpushed: true,
281            }
282            .to_string(),
283            "worktree has uncommitted changes and has unpushed work; use --force to remove anyway"
284        );
285        assert_eq!(
286            Error::RemoveGuarded {
287                dirty: false,
288                unpushed: true,
289            }
290            .to_string(),
291            "worktree has unpushed work; use --force to remove anyway"
292        );
293        // Names wt's own flag, never git's `remove -f -f` (issue #126).
294        assert_eq!(
295            Error::WorktreeLocked {
296                reason: Some("some reason".into()),
297            }
298            .to_string(),
299            "worktree is locked (some reason); use --locked"
300        );
301        assert_eq!(
302            Error::WorktreeLocked { reason: None }.to_string(),
303            "worktree is locked; use --locked"
304        );
305        assert_eq!(Error::WorktreeLocked { reason: None }.exit_code(), 1);
306        assert_eq!(Error::GhUnavailable("nope".into()).to_string(), "nope");
307        assert_eq!(Error::AgentUnavailable("nope".into()).to_string(), "nope");
308        assert_eq!(Error::operation("op").to_string(), "op");
309        assert_eq!(Error::from(std::io::Error::other("io")).to_string(), "io");
310    }
311}