gwm/error.rs
1use thiserror::Error;
2
3pub type Result<T> = std::result::Result<T, GwmError>;
4
5/// Which side of a `gwm link` / `gwm open` pair is missing on a branch.
6/// Carried by [`GwmError::LinkMissing`] so the user sees whether the
7/// issue or the PR slot is empty — both share the same git-config
8/// shape (`branch.<name>.gwm-issue` / `branch.<name>.gwm-pr`) so the
9/// error message must spell out which one was queried.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum LinkKind {
12 Issue,
13 Pr,
14}
15
16impl LinkKind {
17 fn as_str(self) -> &'static str {
18 match self {
19 LinkKind::Issue => "issue",
20 LinkKind::Pr => "PR",
21 }
22 }
23}
24
25impl std::fmt::Display for LinkKind {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.write_str(self.as_str())
28 }
29}
30
31#[derive(Debug, Error)]
32pub enum GwmError {
33 #[error("not inside a git repository")]
34 NotInGitRepo,
35
36 #[error("git error: {0}")]
37 Git(#[from] git2::Error),
38
39 #[error("io error: {0}")]
40 Io(#[from] std::io::Error),
41
42 #[error("toml parse error: {0}")]
43 TomlParse(#[from] toml::de::Error),
44
45 #[error("toml serialize error: {0}")]
46 TomlSer(#[from] toml::ser::Error),
47
48 #[error("regex error: {0}")]
49 Regex(#[from] regex::Error),
50
51 #[error("shell expand error: {0}")]
52 ShellExpand(#[from] shellexpand::LookupError<std::env::VarError>),
53
54 #[error("invalid branch type '{got}' (allowed: {allowed})")]
55 InvalidBranchType { got: String, allowed: String },
56
57 #[error("invalid issue number '{0}' (digits only)")]
58 InvalidIssue(String),
59
60 #[error("invalid description '{0}' (kebab-case alphanumeric)")]
61 InvalidDescription(String),
62
63 #[error("worktree '{0}' not found")]
64 WorktreeNotFound(String),
65
66 #[error("worktree '{0}' already exists at {1}")]
67 WorktreeExists(String, String),
68
69 /// `gwm create` refuses to silently reuse a pre-existing local branch
70 /// (issue #99). The caller must opt in explicitly (`--reuse-branch` /
71 /// `reuse_branch: true`) to attach the new worktree to the existing
72 /// branch tip; otherwise this surfaces so the user can delete the
73 /// stale ref or rename their request rather than ending up on
74 /// whatever commit the stale branch resurrected.
75 #[error(
76 "branch '{name}' already exists at {oid} — pass --reuse-branch to attach the worktree to it, or delete the stale branch first"
77 )]
78 BranchExists { name: String, oid: String },
79
80 #[error("guard '{name}' tripped: file {file} matches deny pattern")]
81 GuardTripped { name: String, file: String },
82
83 /// Generic command/spawn failure. The variant is shared between
84 /// every subcommand that shells out (bootstrap steps, `gwm tmux`,
85 /// `gwm zellij`, the `git log` / `git status` previews in the TUI
86 /// sidebar, …); callers prepend their own operation name into the
87 /// inner string so the rendered message stays attributable to the
88 /// verb the user actually typed.
89 #[error("command failed: {0}")]
90 CommandFailed(String),
91
92 #[error("config error: {0}")]
93 Config(String),
94
95 /// Issue #105: HEAD is unborn (no commits yet) or detached when a
96 /// command that needs the current branch shorthand is invoked.
97 /// Split out of `Other` so callers (and the TUI status line) can
98 /// distinguish "no current branch" from arbitrary string errors.
99 #[error("{reason}")]
100 UnbornHead { reason: String },
101
102 /// Issue #105: failed to deserialize a `gh` CLI JSON payload.
103 /// `kind` names the payload (`"issue"`, `"pr"`, `"pr list"`,
104 /// `"labels"`, `"milestones"`) so the user can grep for which
105 /// gh contract changed; `source` carries the underlying
106 /// `serde_json::Error` for downstream introspection.
107 #[error("failed to parse {kind} json: {source}")]
108 GhJsonParse {
109 kind: &'static str,
110 #[source]
111 source: serde_json::Error,
112 },
113
114 /// Issue #38: failed to serialize a `--format=json` / daemon JSON-RPC
115 /// payload. The output DTOs in `json_api` are plain structs so this is
116 /// effectively unreachable, but surfacing it as a typed error keeps the
117 /// JSON output paths off `unwrap`/`expect` (CLAUDE.md house rule).
118 #[error("failed to serialize json output: {0}")]
119 JsonSerialize(#[from] serde_json::Error),
120
121 /// Issue #105: `gwm open` / `gwm link` was asked for an issue or PR
122 /// linked to a branch but no such link is recorded in git-config.
123 /// `kind` names which side (issue vs PR) is missing; `branch` is
124 /// the branch shorthand the user queried.
125 #[error("no {kind} linked to branch '{branch}'")]
126 LinkMissing { kind: LinkKind, branch: String },
127
128 /// Issue #36: `--workspace <dir>` pointed at a directory that holds no
129 /// git repos directly below it. Surfaced rather than opening an empty
130 /// table / TUI so the user can tell a wrong path from an empty root.
131 #[error("no git repos found directly under workspace root '{root}'")]
132 EmptyWorkspace { root: String },
133
134 /// Issue #36: `gwm create` in workspace mode is ambiguous without an
135 /// explicit target repo — list the candidates so the user can pick one.
136 #[error("workspace mode: `gwm create` requires --repo <name> (one of: {available})")]
137 WorkspaceRepoRequired { available: String },
138
139 /// Issue #36: `--repo <name>` named a repo that is not present directly
140 /// under the workspace root.
141 #[error("repo '{name}' not found in workspace (available: {available})")]
142 WorkspaceRepoNotFound { name: String, available: String },
143
144 /// Issue #36: `--workspace` is a global flag, so clap accepts it for every
145 /// subcommand, but only `list`, `create` and bare `gwm` (the TUI) implement
146 /// it. Reject it elsewhere rather than silently ignoring it and acting on
147 /// the current single repo — a wrong-target footgun for destructive
148 /// commands like `gwm remove` (Codex review #303 P2).
149 #[error("--workspace is only supported with `gwm list`, `gwm create`, `gwm exec`, `gwm clean`, or bare `gwm` (the TUI) — refusing to run this subcommand against a single repo")]
150 WorkspaceUnsupportedCommand,
151
152 #[error("{0}")]
153 Other(String),
154}
155
156impl From<anyhow::Error> for GwmError {
157 fn from(e: anyhow::Error) -> Self {
158 GwmError::Other(e.to_string())
159 }
160}