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 /// A `gwm create --name` value git or the filesystem would refuse
64 /// (issue #416). Free-form names skip the `<type>/#<issue>-<desc>`
65 /// convention entirely, so the only bar left is "can this be a branch
66 /// and a directory" — `{reason}` says which of the two objected.
67 #[error("invalid worktree name '{name}': {reason}")]
68 InvalidWorktreeName { name: String, reason: String },
69
70 #[error("worktree '{0}' not found")]
71 WorktreeNotFound(String),
72
73 #[error("worktree '{0}' already exists at {1}")]
74 WorktreeExists(String, String),
75
76 /// `gwm create` refuses to silently reuse a pre-existing local branch
77 /// (issue #99). The caller must opt in explicitly (`--reuse-branch` /
78 /// `reuse_branch: true`) to attach the new worktree to the existing
79 /// branch tip; otherwise this surfaces so the user can delete the
80 /// stale ref or rename their request rather than ending up on
81 /// whatever commit the stale branch resurrected.
82 #[error(
83 "branch '{name}' already exists at {oid} — pass --reuse-branch to attach the worktree to it, or delete the stale branch first"
84 )]
85 BranchExists { name: String, oid: String },
86
87 #[error("guard '{name}' tripped: file {file} matches deny pattern")]
88 GuardTripped { name: String, file: String },
89
90 /// Generic command/spawn failure. The variant is shared between
91 /// every subcommand that shells out (bootstrap steps, `gwm tmux`,
92 /// `gwm zellij`, the `git log` / `git status` previews in the TUI
93 /// sidebar, …); callers prepend their own operation name into the
94 /// inner string so the rendered message stays attributable to the
95 /// verb the user actually typed.
96 #[error("command failed: {0}")]
97 CommandFailed(String),
98
99 #[error("config error: {0}")]
100 Config(String),
101
102 /// Issue #105: HEAD is unborn (no commits yet) or detached when a
103 /// command that needs the current branch shorthand is invoked.
104 /// Split out of `Other` so callers (and the TUI status line) can
105 /// distinguish "no current branch" from arbitrary string errors.
106 #[error("{reason}")]
107 UnbornHead { reason: String },
108
109 /// Issue #105: failed to deserialize a forge CLI JSON payload.
110 /// `kind` names the payload (`"issue"`, `"pr"`, `"pr list"`,
111 /// `"labels"`, `"milestones"`, and the `"gitlab …"` variants since
112 /// #419) so the user can grep for which forge contract changed;
113 /// `source` carries the underlying `serde_json::Error` for downstream
114 /// introspection.
115 #[error("failed to parse {kind} json: {source}")]
116 GhJsonParse {
117 kind: &'static str,
118 #[source]
119 source: serde_json::Error,
120 },
121
122 /// Issue #38: failed to serialize a `--format=json` / daemon JSON-RPC
123 /// payload. The output DTOs in `json_api` are plain structs so this is
124 /// effectively unreachable, but surfacing it as a typed error keeps the
125 /// JSON output paths off `unwrap`/`expect` (CLAUDE.md house rule).
126 #[error("failed to serialize json output: {0}")]
127 JsonSerialize(#[from] serde_json::Error),
128
129 /// Issue #105: `gwm open` / `gwm link` was asked for an issue or PR
130 /// linked to a branch but no such link is recorded in git-config.
131 /// `kind` names which side (issue vs PR) is missing; `branch` is
132 /// the branch shorthand the user queried.
133 #[error("no {kind} linked to branch '{branch}'")]
134 LinkMissing { kind: LinkKind, branch: String },
135
136 /// Issue #36: `--workspace <dir>` pointed at a directory that holds no
137 /// git repos directly below it. Surfaced rather than opening an empty
138 /// table / TUI so the user can tell a wrong path from an empty root.
139 #[error("no git repos found directly under workspace root '{root}'")]
140 EmptyWorkspace { root: String },
141
142 /// Issue #36: `gwm create` in workspace mode is ambiguous without an
143 /// explicit target repo — list the candidates so the user can pick one.
144 #[error("workspace mode: `gwm create` requires --repo <name> (one of: {available})")]
145 WorkspaceRepoRequired { available: String },
146
147 /// Issue #36: `--repo <name>` named a repo that is not present directly
148 /// under the workspace root.
149 #[error("repo '{name}' not found in workspace (available: {available})")]
150 WorkspaceRepoNotFound { name: String, available: String },
151
152 /// Issue #36: `--workspace` is a global flag, so clap accepts it for every
153 /// subcommand, but only `list`, `create` and bare `gwm` (the TUI) implement
154 /// it. Reject it elsewhere rather than silently ignoring it and acting on
155 /// the current single repo — a wrong-target footgun for destructive
156 /// commands like `gwm remove` (Codex review #303 P2).
157 #[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")]
158 WorkspaceUnsupportedCommand,
159
160 #[error("{0}")]
161 Other(String),
162}
163
164impl From<anyhow::Error> for GwmError {
165 fn from(e: anyhow::Error) -> Self {
166 GwmError::Other(e.to_string())
167 }
168}