gwm-cli 1.6.1

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
Documentation
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())
  }
}