Skip to main content

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}