//! The command surface.
//!
//! The vocabulary follows the Android client's tabs — repos, CI, issues,
//! pulls, wiki, packages — because the two speak the same API and there's
//! no reason for the same concept to be called two things.
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
#[derive(Debug, Parser)]
#[command(
name = "gg",
version,
about = "Command-line client for a GitGud forge",
long_about = "Talks to the same /api/mobile/v1 surface the Android app uses.\n\
Sign in once with `gg login`, then work from any repository \
checkout — the repo is taken from your git remote unless you pass --repo.",
propagate_version = true
)]
pub struct Cli {
/// Forge base URL (default: the one saved by `gg login`).
#[arg(long, global = true, value_name = "URL", env = crate::config::ENV_URL)]
pub url: Option<String>,
/// API token (default: the one saved by `gg login`).
#[arg(long, global = true, value_name = "TOKEN", env = crate::config::ENV_TOKEN, hide_env_values = true)]
pub token: Option<String>,
/// Repository as owner/name. Inferred from the git remote if omitted.
#[arg(short = 'R', long, global = true, value_name = "OWNER/NAME", env = crate::config::ENV_REPO)]
pub repo: Option<String>,
/// Print the server's JSON instead of a table.
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Save credentials for a forge and verify them.
Login(LoginArgs),
/// Forget the saved credentials.
Logout,
/// Show who the saved token belongs to, and what it may do.
Whoami,
/// List the repositories this token can reach.
#[command(name = "repos")]
RepoList(RepoListArgs),
/// Show one repository: counts, permissions, latest run.
Repo(RepoArgs),
/// Workflow runs, jobs and logs.
Ci(CiArgs),
/// Issues.
#[command(visible_alias = "issues")]
Issue(IssueArgs),
/// Pull requests.
#[command(visible_aliases = ["pulls", "pr"])]
Pull(PullArgs),
/// A repository's labels: list them, or create one.
#[command(name = "labels", visible_alias = "label")]
Labels(LabelsArgs),
/// Wiki pages.
Wiki(WikiArgs),
/// Published packages and how to consume them.
#[command(visible_alias = "packages")]
Package(PackageArgs),
/// Interactive CI dashboard.
#[command(visible_aliases = ["tui", "ui"])]
Dashboard(DashboardArgs),
}
// ── auth ─────────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct LoginArgs {
/// A `gitgud://pair?base=…&token=…` URI — the same one the pairing QR
/// encodes. Pass it instead of --url/--token.
#[arg(value_name = "PAIRING_URI")]
pub pairing_uri: Option<String>,
/// Read the token from stdin rather than an argument, so it stays out
/// of the shell history and the process table.
#[arg(long, conflicts_with = "pairing_uri")]
pub token_stdin: bool,
/// Remember this repository when no git remote names one.
#[arg(long, value_name = "OWNER/NAME")]
pub default_repo: Option<String>,
}
// ── repositories ─────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct RepoListArgs {
/// Order of the listing.
#[arg(long, value_enum, default_value_t = RepoSort::Pushed)]
pub sort: RepoSort,
/// Only repositories whose name or description matches.
#[arg(long, value_name = "TEXT")]
pub search: Option<String>,
/// Include archived repositories.
#[arg(long)]
pub archived: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum RepoSort {
/// Most recently pushed first.
Pushed,
/// Alphabetical.
Name,
/// Failures first, then running, then queued.
Ci,
}
#[derive(Debug, Args)]
pub struct RepoArgs {
/// Repository as owner/name (default: --repo, or the git remote).
#[arg(value_name = "OWNER/NAME")]
pub target: Option<String>,
}
// ── CI ───────────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct CiArgs {
#[command(subcommand)]
pub command: CiCommand,
}
#[derive(Debug, Subcommand)]
pub enum CiCommand {
/// Recent workflow runs.
#[command(visible_alias = "runs")]
List {
/// How many runs to fetch (server caps this at 200).
#[arg(long, default_value_t = 20)]
limit: u32,
},
/// One run, with its jobs, steps and artifacts.
#[command(visible_alias = "run")]
Show {
/// Run number. Defaults to the most recent run.
number: Option<i64>,
/// Also show each job's steps.
#[arg(long)]
steps: bool,
},
/// A job's logs, grouped by step.
Logs {
/// Run number. Defaults to the most recent run.
number: Option<i64>,
/// Which job, by name or workflow id. Defaults to the only job, or
/// to the failed one when there's exactly one.
#[arg(long, value_name = "JOB")]
job: Option<String>,
/// Maximum log lines (server caps this at 10000).
#[arg(long)]
limit: Option<u32>,
/// Drop the step headers and print the log body alone, for piping.
#[arg(long)]
raw: bool,
},
/// Re-run a finished run, or one job within it.
Rerun {
/// Run number.
number: i64,
/// Re-run only this job, by its workflow id.
#[arg(long, value_name = "JOB")]
job: Option<String>,
},
/// Cancel a run that's queued or in flight.
Cancel {
/// Run number.
number: i64,
},
/// Follow a run until it finishes.
Watch {
/// Run number. Defaults to the most recent run.
number: Option<i64>,
/// Seconds between polls.
#[arg(long, default_value_t = 5)]
interval: u64,
},
/// Workflows that can be run by hand (`on: workflow_dispatch` on the
/// default branch), with the inputs each declares.
Workflows,
/// Run a workflow by hand (`workflow_dispatch`).
#[command(visible_alias = "run-workflow")]
Dispatch {
/// The workflow, by path or file name (see `gg ci workflows`).
workflow: String,
/// Branch or tag to run it on. Defaults to the default branch.
#[arg(long = "ref", value_name = "REF")]
git_ref: Option<String>,
/// An input, as NAME=VALUE. Repeat for each; booleans are
/// `true`/`false`. Undeclared ones are refused by the server.
#[arg(short = 'f', long = "field", value_name = "NAME=VALUE", value_parser = parse_field)]
fields: Vec<(String, String)>,
/// Follow the new run until it finishes, as `gg ci watch` does.
/// Ignored with --json.
#[arg(long)]
watch: bool,
/// Seconds between polls with --watch.
#[arg(long, default_value_t = 5, requires = "watch")]
interval: u64,
},
}
/// `NAME=VALUE`, split at the first `=` so a value may contain more.
fn parse_field(raw: &str) -> Result<(String, String), String> {
match raw.split_once('=') {
Some((name, value)) if !name.trim().is_empty() => {
Ok((name.trim().to_string(), value.to_string()))
}
_ => Err(format!("expected NAME=VALUE, got {raw:?}")),
}
}
// ── issues ───────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct IssueArgs {
#[command(subcommand)]
pub command: IssueCommand,
}
#[derive(Debug, Subcommand)]
pub enum IssueCommand {
/// List issues.
#[command(visible_alias = "ls")]
List {
#[arg(long, value_enum, default_value_t = IssueState::Open)]
state: IssueState,
/// Full-text search over title and body.
#[arg(long, value_name = "TEXT")]
search: Option<String>,
#[arg(long)]
limit: Option<u32>,
},
/// Show one issue and its comments.
Show {
number: i64,
/// Hide the comment thread.
#[arg(long)]
no_comments: bool,
},
/// Add a comment.
Comment {
number: i64,
#[command(flatten)]
body: BodyArgs,
},
/// Close an issue.
Close { number: i64 },
/// Reopen a closed issue.
Reopen { number: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum IssueState {
Open,
Closed,
All,
}
impl IssueState {
pub fn as_param(self) -> &'static str {
match self {
IssueState::Open => "open",
IssueState::Closed => "closed",
IssueState::All => "all",
}
}
}
// ── pull requests ────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct PullArgs {
#[command(subcommand)]
pub command: PullCommand,
}
#[derive(Debug, Subcommand)]
pub enum PullCommand {
/// List pull requests.
#[command(visible_alias = "ls")]
List {
#[arg(long, value_enum, default_value_t = PullState::Open)]
state: PullState,
#[arg(long, value_name = "TEXT")]
search: Option<String>,
#[arg(long)]
limit: Option<u32>,
},
/// Open a pull request. Prints its number and URL.
#[command(visible_alias = "new")]
Create {
/// The branch to merge (default: the branch checked out here). It
/// has to be pushed already.
#[arg(long, value_name = "BRANCH")]
head: Option<String>,
/// The branch to merge it into (default: the repository's default
/// branch).
#[arg(long, value_name = "BRANCH")]
base: Option<String>,
/// Take the head branch from this repository — a fork, or any
/// repository sharing this one's history that you can read. The
/// pull request opens on --repo.
#[arg(long, value_name = "OWNER/NAME")]
head_repo: Option<String>,
/// The pull request's title.
#[arg(long, value_name = "TEXT")]
title: String,
#[command(flatten)]
body: OptionalBodyArgs,
},
/// Change a pull request's title or description. Its author or a
/// repository admin.
#[command(group(ArgGroup::new("change").required(true).multiple(true).args(["title", "body", "body_file"])))]
Edit {
number: i64,
/// The new title.
#[arg(long, value_name = "TEXT")]
title: Option<String>,
/// The new description, replacing the old one. Empty clears it.
#[arg(long, value_name = "TEXT")]
body: Option<String>,
/// Read the new description from a file, or from stdin with `-`.
#[arg(long, value_name = "PATH", conflicts_with = "body")]
body_file: Option<String>,
},
/// Close a pull request without merging it. Its author or a
/// repository admin.
Close { number: i64 },
/// Reopen a closed pull request. A merged one stays merged.
Reopen { number: i64 },
/// Add or remove labels. Its author or a repository admin; the labels
/// have to exist (see `gg labels`).
#[command(group(ArgGroup::new("change").required(true).multiple(true).args(["add", "remove"])))]
Label {
number: i64,
/// A label to add. Repeat for more.
#[arg(long, value_name = "LABEL")]
add: Vec<String>,
/// A label to remove. Repeat for more.
#[arg(long, value_name = "LABEL")]
remove: Vec<String>,
},
/// The labels a pull request in this repository can be given.
Labels,
/// Show one pull request: discussion, reviews and merge blockers.
Show {
number: i64,
#[arg(long)]
no_comments: bool,
},
/// Add a comment to the discussion.
Comment {
number: i64,
#[command(flatten)]
body: BodyArgs,
},
/// Leave a review verdict. One standing verdict per reviewer —
/// posting again replaces your previous one.
Review {
number: i64,
#[arg(long, value_enum)]
state: ReviewState,
#[command(flatten)]
body: OptionalBodyArgs,
},
/// Merge a pull request.
Merge {
number: i64,
/// Merge commit message.
#[arg(long, value_name = "TEXT")]
message: Option<String>,
/// Bypass the repository's review rules. Repo admins only; the
/// server refuses outright rather than quietly merging normally.
#[arg(long)]
override_rules: bool,
},
/// Let a fork's workflow runs on the current head start. They run
/// the fork's code, without this repository's secrets. Needs write
/// access.
ApproveRuns { number: i64 },
/// Cancel a fork's workflow runs waiting for approval, on any head.
/// Needs write access.
RejectRuns { number: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PullState {
Open,
Closed,
Merged,
All,
}
impl PullState {
pub fn as_param(self) -> &'static str {
match self {
PullState::Open => "open",
PullState::Closed => "closed",
PullState::Merged => "merged",
PullState::All => "all",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReviewState {
Approve,
RequestChanges,
Comment,
}
impl ReviewState {
pub fn as_param(self) -> &'static str {
match self {
ReviewState::Approve => "approved",
ReviewState::RequestChanges => "changes_requested",
ReviewState::Comment => "commented",
}
}
}
// ── labels ───────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct LabelsArgs {
/// What to do; lists them when omitted.
#[command(subcommand)]
pub command: Option<LabelCommand>,
}
#[derive(Debug, Subcommand)]
pub enum LabelCommand {
/// The repository's labels, including those inherited from its
/// organization.
#[command(visible_alias = "ls")]
List,
/// Create a repository label. Repository admins only.
Create {
name: String,
/// As #RRGGBB (default: a neutral slate).
#[arg(long, value_name = "#RRGGBB")]
color: Option<String>,
#[arg(long, value_name = "TEXT")]
description: Option<String>,
},
}
// ── wiki ─────────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct WikiArgs {
#[command(subcommand)]
pub command: WikiCommand,
}
#[derive(Debug, Subcommand)]
pub enum WikiCommand {
/// List wiki pages.
#[command(visible_alias = "ls")]
List,
/// Print one page's markdown.
Show {
/// Page slug, as shown by `gg wiki list`.
slug: String,
},
}
// ── packages ─────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct PackageArgs {
#[command(subcommand)]
pub command: PackageCommand,
}
#[derive(Debug, Subcommand)]
pub enum PackageCommand {
/// List published packages, grouped by format.
#[command(visible_alias = "ls")]
List,
/// Show one package's versions and files.
Show {
/// Registry format, e.g. `hex`, `npm`, `container`.
format: String,
/// Package name.
name: String,
},
/// Print the forge's own setup instructions for a format.
Usage {
/// Registry format, e.g. `hex`, `npm`, `container`.
format: String,
},
}
// ── dashboard ────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct DashboardArgs {
/// Seconds between background refreshes.
#[arg(long, default_value_t = 10)]
pub refresh: u64,
/// How many runs to keep in the list.
#[arg(long, default_value_t = 50)]
pub limit: u32,
}
// ── shared ───────────────────────────────────────────────────────────
/// A comment body: required, from wherever the user finds it easiest.
#[derive(Debug, Args)]
pub struct BodyArgs {
/// The text itself.
#[arg(long, value_name = "TEXT")]
pub body: Option<String>,
/// Read the body from a file, or from stdin with `-`.
#[arg(long, value_name = "PATH", conflicts_with = "body")]
pub body_file: Option<String>,
}
/// The same, where the server will take an empty one.
#[derive(Debug, Args)]
pub struct OptionalBodyArgs {
/// The text itself (optional).
#[arg(long, value_name = "TEXT")]
pub body: Option<String>,
/// Read it from a file, or from stdin with `-`.
#[arg(long, value_name = "PATH", conflicts_with = "body")]
pub body_file: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn dispatch_fields_split_at_the_first_equals() {
assert_eq!(
parse_field("query=a=b").unwrap(),
("query".to_string(), "a=b".to_string())
);
assert_eq!(parse_field("flag=").unwrap().1, "");
assert!(parse_field("novalue").is_err());
assert!(parse_field("=x").is_err());
}
#[test]
fn dispatch_parses_repeated_fields() {
let cli = Cli::try_parse_from([
"gg",
"ci",
"dispatch",
"deploy.yml",
"--ref",
"main",
"-f",
"env=prod",
"-f",
"dry_run=true",
])
.unwrap();
let Command::Ci(CiArgs {
command:
CiCommand::Dispatch {
workflow,
git_ref,
fields,
..
},
}) = cli.command
else {
panic!("not a dispatch");
};
assert_eq!(workflow, "deploy.yml");
assert_eq!(git_ref.as_deref(), Some("main"));
assert_eq!(fields.len(), 2);
}
#[test]
fn pull_create_defaults_head_and_base_to_none() {
let cli = Cli::try_parse_from(["gg", "pr", "create", "--title", "Fix it"]).unwrap();
let Command::Pull(PullArgs {
command: PullCommand::Create {
head, base, title, ..
},
}) = cli.command
else {
panic!("not a create");
};
assert_eq!(title, "Fix it");
assert!(head.is_none() && base.is_none());
assert!(
Cli::try_parse_from(["gg", "pull", "create"]).is_err(),
"title is required"
);
}
#[test]
fn pull_label_takes_repeated_adds_and_removes_but_needs_one() {
let cli = Cli::try_parse_from([
"gg", "pull", "label", "7", "--add", "bug", "--add", "ui", "--remove", "triage",
])
.unwrap();
let Command::Pull(PullArgs {
command:
PullCommand::Label {
number,
add,
remove,
},
}) = cli.command
else {
panic!("not a label");
};
assert_eq!(number, 7);
assert_eq!(add, ["bug", "ui"]);
assert_eq!(remove, ["triage"]);
assert!(Cli::try_parse_from(["gg", "pull", "label", "7"]).is_err());
}
#[test]
fn pull_edit_needs_something_to_change() {
assert!(Cli::try_parse_from(["gg", "pull", "edit", "3"]).is_err());
assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--title", "New"]).is_ok());
assert!(Cli::try_parse_from(["gg", "pull", "edit", "3", "--body-file", "-"]).is_ok());
}
#[test]
fn labels_lists_without_a_subcommand() {
let cli = Cli::try_parse_from(["gg", "labels"]).unwrap();
assert!(matches!(
cli.command,
Command::Labels(LabelsArgs { command: None })
));
let cli =
Cli::try_parse_from(["gg", "label", "create", "bug", "--color", "#ff0000"]).unwrap();
assert!(matches!(
cli.command,
Command::Labels(LabelsArgs {
command: Some(LabelCommand::Create { .. })
})
));
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
21.0 KiB · text
History
df92b0a