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