Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

The g1t command line does a forge's daily work now, for people at a terminal and for agents in a shell, not only g1t push. g1t auth login signs in by approving a code in the browser (the API's device sign-in), or takes a token on stdin with --with-token; the token goes in the system keychain (macOS Keychain, Windows Credential Manager, the Secret Service), or in config.json readable by its owner only when there is none, and G1T_TOKEN overrides both. auth status, logout, token and switch handle several accounts on several hosts, g1t.sh and self-hosted ones (--hostname, G1T_HOST; a new host is asked where its API is through /.well-known/g1t.json). auth setup-git makes g1t git's credential helper for the host, and g1t push takes the same signed-in token before docker's.

Commands: repo view, clone, create and list; issue list, view, create, comment, close, reopen and edit; pr list, view, create, checkout, diff, checks, review, merge, ready, comment, close and reopen; run list, view, watch, logs, rerun and cancel; secret list, set and delete for a repository or a workspace, per environment, brokered or not; deploy list and view; logs, a project's log lines or its errors grouped, with --follow; status, which gathers your open pull requests, reviews asked of you, what is assigned to you and failing checks; browse; alias; completion for bash, zsh, fish, PowerShell and elvish; and api, which calls any operation by its name (g1t api list_issues -f state=closed) or any path, with --paginate and --slurp. The repository comes from --repo, G1T_REPO or the git remote, including a pull request's own working copy. Every command takes --json, every field or a comma-separated few, and --jq with a jq expression built in (including @csv and @tsv). JSON is compact with its keys in a stable order when piped; tables are aligned on a terminal and tab-separated without a header when piped; lists default to 20 entries. Nothing is asked unless stdin and stderr are a terminal. Errors are one line on stderr, and exit codes are stable: 1 failed, 2 used wrongly, 4 sign-in needed, 8 checks still pending. For agents, pr checks --watch --fail-fast stops at the first failing check, and run logs --failed prints only the last lines of the steps that failed. Commands never spell out a path. ops_table.rs is generated from the API's OpenAPI document, and a test fails when it falls behind (G1T_WRITE_OPS=1 cargo test -p g1t ops_table regenerates it); mapping.rs lists the operations each command calls, and its tests fail when a command calls an operation the API no longer has, when a command is missing from the list, or when the list and the code disagree. Every operation is reachable through g1t api. Integration tests run the binary against a mock API.

syntaqxcommitted Parent02abd97Browse files
34 files+16299−2982 files not counted0/34 viewed
+1033−15
A large diff (1,467 lines) is not shown until you ask for it.
+17−2
11 [package]
22 name = "g1t"
3−version = "0.1.0"
3+version = "0.2.0"
44 edition.workspace = true
55 license.workspace = true
66 repository.workspace = true
7−description = "The g1t command line: g1t push sends a local image to g1t.sh in chunks, so a layer can be any size."
7+description = "The g1t command line: repositories, issues, pull requests, workflow runs, deployments, secrets and logs on g1t, from a terminal or an agent's shell."
8+
9+[[bin]]
10+name = "g1t"
11+path = "src/main.rs"
812
913 [dependencies]
14+anstyle-query = "1.1"
1015 base64 = "0.22"
16+clap = { version = "4.6", features = ["derive", "env", "wrap_help"] }
17+clap_complete = "4.6"
1118 flate2 = "1"
1219 hex = "0.4"
20+jaq-core = "3.1"
21+jaq-json = "2.0"
22+jaq-std = "3.0"
23+keyring = "4.2"
24+rpassword = "7"
25+serde = { workspace = true }
1326 serde_json = { workspace = true }
1427 sha2 = "0.10"
1528 tar = { version = "0.4", default-features = false }
29+terminal_size = "0.4"
1630 ureq = { version = "2", default-features = false, features = ["tls"] }
31+
+354−0
1+//! Hosts and their tokens.
2+//!
3+//! A host is a g1t: g1t.sh, or one run by its owner. Each has a site (git
4+//! remotes, pages) and an API; g1t.sh's are built in, and any other is found
5+//! from its `/.well-known/g1t.json` when signing in.
6+//!
7+//! A token is the first of:
8+//!
9+//! 1. `G1T_TOKEN`, for whatever host the command talks to;
10+//! 2. the system keychain (macOS Keychain, Windows Credential Manager, the
11+//! Secret Service on Linux), under the service `g1t:<host>`;
12+//! 3. `config.json`, where a token goes only when there is no keychain
13+//! (a server, a container) or `--insecure-storage` asked for it.
14+//!
15+//! `G1T_KEYRING=off` keeps the keychain out of it entirely.
16+
17+use serde_json::Value;
18+
19+use crate::config::{Config, DEFAULT_HOST, HostConfig, UserConfig};
20+use crate::error::{Error, Result};
21+
22+#[derive(Clone, Debug, PartialEq, Eq)]
23+pub struct Host {
24+ /// How it is named: `g1t.sh`, `git.example.com`, `localhost:8787`.
25+ pub name: String,
26+ pub site: String,
27+ pub api: String,
28+}
29+
30+impl Host {
31+ pub fn hosted() -> Host {
32+ Host {
33+ name: DEFAULT_HOST.to_owned(),
34+ site: "https://g1t.sh".to_owned(),
35+ api: "https://api.g1t.sh".to_owned(),
36+ }
37+ }
38+}
39+
40+/// `git.example.com`, `https://git.example.com/` or `http://localhost:8787`:
41+/// the host's name and its site's address.
42+pub fn parse_hostname(input: &str) -> Result<(String, String)> {
43+ let input = input.trim().trim_end_matches('/');
44+ let (scheme, rest) = match input.split_once("://") {
45+ Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
46+ Some((scheme, _)) => {
47+ return Err(Error::usage(format!(
48+ "`{scheme}://` is not a g1t address: use https://."
49+ )));
50+ }
51+ None => ("https", input),
52+ };
53+ let name = rest.split('/').next().unwrap_or(rest).to_lowercase();
54+ if name.is_empty() || name.contains(char::is_whitespace) {
55+ return Err(Error::usage(format!("`{input}` is not a hostname.")));
56+ }
57+ Ok((name.clone(), format!("{scheme}://{name}")))
58+}
59+
60+/// The host named by `--hostname`/`G1T_HOST`, or the default one.
61+pub fn resolve(config: &Config, hostname: Option<&str>) -> Result<Host> {
62+ let wanted = match hostname {
63+ Some(h) if !h.trim().is_empty() => h.to_owned(),
64+ _ => match std::env::var("G1T_HOST")
65+ .ok()
66+ .filter(|h| !h.trim().is_empty())
67+ {
68+ Some(h) => h,
69+ None => config
70+ .default_host
71+ .clone()
72+ .unwrap_or_else(|| DEFAULT_HOST.to_owned()),
73+ },
74+ };
75+ let (name, site) = parse_hostname(&wanted)?;
76+ let mut host = match config.host(&name) {
77+ Some(h) => Host {
78+ name,
79+ site: h.site.clone(),
80+ api: h.api.clone(),
81+ },
82+ None if name == DEFAULT_HOST => Host::hosted(),
83+ // Not signed in to it: guess the API's address the way g1t is set
84+ // up (api.<host>); `auth login` asks the host instead.
85+ None => {
86+ let api = site.replacen("://", "://api.", 1);
87+ Host { name, site, api }
88+ }
89+ };
90+ if let Some(api) = std::env::var("G1T_API_URL")
91+ .ok()
92+ .filter(|a| !a.trim().is_empty())
93+ {
94+ host.api = api.trim_end_matches('/').to_owned();
95+ }
96+ Ok(host)
97+}
98+
99+/// Asks `site` where its API is.
100+pub fn discover(name: &str, site: &str) -> Result<Host> {
101+ if name == DEFAULT_HOST {
102+ return Ok(Host::hosted());
103+ }
104+ let url = format!("{site}/.well-known/g1t.json");
105+ let text = ureq::get(&url)
106+ .timeout(std::time::Duration::from_secs(15))
107+ .call()
108+ .map_err(|e| Error::failed(format!("{name} did not say where its API is ({url}): {e}")))?
109+ .into_string()
110+ .map_err(|e| Error::failed(format!("Could not read {url}: {e}")))?;
111+ let found: Value = serde_json::from_str(&text)
112+ .map_err(|e| Error::failed(format!("{url} is not g1t's: {e}")))?;
113+ let api = found["api"]
114+ .as_str()
115+ .ok_or_else(|| Error::failed(format!("{url} names no API.")))?;
116+ let site_said = found["site"].as_str().unwrap_or(site);
117+ Ok(Host {
118+ name: name.to_owned(),
119+ site: site_said.trim_end_matches('/').to_owned(),
120+ api: api.trim_end_matches('/').to_owned(),
121+ })
122+}
123+
124+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125+pub enum Source {
126+ Env,
127+ Keyring,
128+ File,
129+}
130+
131+impl Source {
132+ pub fn label(self) -> &'static str {
133+ match self {
134+ Source::Env => "G1T_TOKEN",
135+ Source::Keyring => "keychain",
136+ Source::File => "config file",
137+ }
138+ }
139+}
140+
141+#[derive(Clone, Debug)]
142+pub struct Token {
143+ pub value: String,
144+ pub source: Source,
145+ /// Whose it is, when the store knows.
146+ pub user: Option<String>,
147+}
148+
149+fn keyring_enabled() -> bool {
150+ !matches!(
151+ std::env::var("G1T_KEYRING")
152+ .ok()
153+ .as_deref()
154+ .map(str::to_lowercase)
155+ .as_deref(),
156+ Some("off" | "0" | "false" | "no")
157+ )
158+}
159+
160+fn service(host: &str) -> String {
161+ format!("g1t:{host}")
162+}
163+
164+fn keyring_get(host: &str, user: &str) -> Option<String> {
165+ if !keyring_enabled() {
166+ return None;
167+ }
168+ let entry = keyring::Entry::new(&service(host), user).ok()?;
169+ entry.get_password().ok().filter(|t| !t.is_empty())
170+}
171+
172+fn keyring_set(host: &str, user: &str, token: &str) -> std::result::Result<(), String> {
173+ if !keyring_enabled() {
174+ return Err("G1T_KEYRING is off".to_owned());
175+ }
176+ let entry = keyring::Entry::new(&service(host), user).map_err(|e| e.to_string())?;
177+ entry.set_password(token).map_err(|e| e.to_string())?;
178+ // Some stores accept a write they cannot read back (a locked keyring).
179+ match entry.get_password() {
180+ Ok(back) if back == token => Ok(()),
181+ _ => Err("the keychain did not keep it".to_owned()),
182+ }
183+}
184+
185+fn keyring_delete(host: &str, user: &str) {
186+ if keyring_enabled()
187+ && let Ok(entry) = keyring::Entry::new(&service(host), user)
188+ {
189+ let _ = entry.delete_credential();
190+ }
191+}
192+
193+/// The token for `host`, and the account it is for when known.
194+pub fn token(config: &Config, host: &str, user: Option<&str>) -> Option<Token> {
195+ if user.is_none()
196+ && let Some(value) = std::env::var("G1T_TOKEN")
197+ .ok()
198+ .map(|t| t.trim().to_owned())
199+ .filter(|t| !t.is_empty())
200+ {
201+ return Some(Token {
202+ value,
203+ source: Source::Env,
204+ user: None,
205+ });
206+ }
207+ let entry = config.host(host)?;
208+ let user = user.map(str::to_owned).or_else(|| entry.user.clone())?;
209+ stored_token(host, &user, entry.users.get(&user))
210+}
211+
212+fn stored_token(host: &str, user: &str, entry: Option<&UserConfig>) -> Option<Token> {
213+ if let Some(value) = entry
214+ .and_then(|u| u.token.clone())
215+ .filter(|t| !t.is_empty())
216+ {
217+ return Some(Token {
218+ value,
219+ source: Source::File,
220+ user: Some(user.to_owned()),
221+ });
222+ }
223+ keyring_get(host, user).map(|value| Token {
224+ value,
225+ source: Source::Keyring,
226+ user: Some(user.to_owned()),
227+ })
228+}
229+
230+/// Keeps `token` for `user` on `host` and makes that account the active one.
231+/// Returns where it went.
232+pub fn store(config: &mut Config, host: &Host, user: &str, token: &str, insecure: bool) -> Source {
233+ let source = if !insecure && keyring_set(&host.name, user, token).is_ok() {
234+ Source::Keyring
235+ } else {
236+ Source::File
237+ };
238+ let entry = config
239+ .hosts
240+ .entry(host.name.clone())
241+ .or_insert_with(|| HostConfig {
242+ site: host.site.clone(),
243+ api: host.api.clone(),
244+ ..HostConfig::default()
245+ });
246+ entry.site = host.site.clone();
247+ entry.api = host.api.clone();
248+ entry.user = Some(user.to_owned());
249+ entry.users.insert(
250+ user.to_owned(),
251+ UserConfig {
252+ token: (source == Source::File).then(|| token.to_owned()),
253+ },
254+ );
255+ if config.default_host.is_none() {
256+ config.default_host = Some(host.name.clone());
257+ }
258+ source
259+}
260+
261+/// Forgets `user` on `host`; the next account there, if any, becomes active.
262+pub fn forget(config: &mut Config, host: &str, user: &str) {
263+ keyring_delete(host, user);
264+ let Some(entry) = config.hosts.get_mut(host) else {
265+ return;
266+ };
267+ entry.users.remove(user);
268+ if entry.user.as_deref() == Some(user) {
269+ entry.user = entry.users.keys().next().cloned();
270+ }
271+ if entry.users.is_empty() {
272+ config.hosts.remove(host);
273+ if config.default_host.as_deref() == Some(host) {
274+ config.default_host = config.hosts.keys().next().cloned();
275+ }
276+ }
277+}
278+
279+/// Every account's token on `host`, for `auth status`.
280+pub fn accounts(config: &Config, host: &str) -> Vec<(String, Option<Token>, bool)> {
281+ let Some(entry) = config.host(host) else {
282+ return Vec::new();
283+ };
284+ entry
285+ .users
286+ .iter()
287+ .map(|(user, u)| {
288+ (
289+ user.clone(),
290+ stored_token(host, user, Some(u)),
291+ entry.user.as_deref() == Some(user),
292+ )
293+ })
294+ .collect()
295+}
296+
297+/// The token `g1t push` signs in to a registry with, when one is stored for
298+/// its host.
299+pub fn registry_token(host: &str) -> Option<String> {
300+ let config = Config::load().ok()?;
301+ let (name, _) = parse_hostname(host).ok()?;
302+ let entry = config.host(&name)?;
303+ let user = entry.user.clone()?;
304+ stored_token(&name, &user, entry.users.get(&user)).map(|t| t.value)
305+}
306+
307+#[cfg(test)]
308+mod tests {
309+ use super::*;
310+
311+ #[test]
312+ fn hostnames_take_any_form() {
313+ assert_eq!(
314+ parse_hostname("g1t.sh").unwrap(),
315+ ("g1t.sh".into(), "https://g1t.sh".into())
316+ );
317+ assert_eq!(
318+ parse_hostname("https://Git.Example.com/").unwrap(),
319+ ("git.example.com".into(), "https://git.example.com".into())
320+ );
321+ assert_eq!(
322+ parse_hostname("http://localhost:8787").unwrap(),
323+ ("localhost:8787".into(), "http://localhost:8787".into())
324+ );
325+ assert!(parse_hostname("ssh://g1t.sh").is_err());
326+ assert!(parse_hostname("").is_err());
327+ }
328+
329+ #[test]
330+ fn file_tokens_are_kept_and_forgotten() {
331+ // SAFETY: tests that touch G1T_KEYRING all set it to off.
332+ unsafe { std::env::set_var("G1T_KEYRING", "off") };
333+ let mut config = Config::default();
334+ let host = Host::hosted();
335+ assert_eq!(
336+ store(&mut config, &host, "ana", "g1t_a", false),
337+ Source::File
338+ );
339+ store(&mut config, &host, "bo", "g1t_b", true);
340+ assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("bo"));
341+ let t = stored_token(
342+ "g1t.sh",
343+ "ana",
344+ config.host("g1t.sh").unwrap().users.get("ana"),
345+ )
346+ .unwrap();
347+ assert_eq!((t.value.as_str(), t.source), ("g1t_a", Source::File));
348+ forget(&mut config, "g1t.sh", "bo");
349+ assert_eq!(config.host("g1t.sh").unwrap().user.as_deref(), Some("ana"));
350+ forget(&mut config, "g1t.sh", "ana");
351+ assert!(config.hosts.is_empty());
352+ assert_eq!(config.default_host, None);
353+ }
354+}
+1−0
22
33 pub const MIB: u64 = 1024 * 1024;
44 /// What a chunk is unless `--chunk-size` says otherwise.
5+#[cfg_attr(not(test), allow(dead_code))]
56 pub const DEFAULT_CHUNK: u64 = 90 * MIB;
67 /// The largest chunk: well under the 100 MB a request to g1t.sh may carry.
78 pub const MAX_CHUNK: u64 = 95 * MIB;
+909−0
1+//! The command line's shape: every command, flag and argument.
2+
3+use clap::{Args, Parser, Subcommand, ValueEnum};
4+use std::path::PathBuf;
5+
6+pub const ABOUT: &str = "Work with g1t from the command line.";
7+
8+pub const AFTER_HELP: &str = "\
9+Examples:
10+ g1t auth login
11+ g1t issue list --label bug
12+ g1t pr create --fill
13+ g1t pr checks --watch --fail-fast
14+ g1t run logs --failed
15+ g1t api list_issues --jq '.[].title'
16+
17+Environment:
18+ G1T_TOKEN A token to use instead of the stored one
19+ G1T_HOST The host to talk to when no repository names one
20+ G1T_REPO The repository, as OWNER/NAME, instead of the git remote
21+ NO_COLOR Plain output on a terminal too
22+
23+Exit codes: 0 done, 1 failed, 2 used wrongly, 4 sign-in needed, 8 checks pending.
24+Docs: https://docs.g1t.sh/guides/cli/";
25+
26+#[derive(Debug, Parser)]
27+#[command(
28+ name = "g1t",
29+ version,
30+ about = ABOUT,
31+ after_help = AFTER_HELP,
32+ propagate_version = true,
33+ max_term_width = 100,
34+ arg_required_else_help = true
35+)]
36+pub struct Cli {
37+ #[command(flatten)]
38+ pub global: Global,
39+ #[command(subcommand)]
40+ pub command: Command,
41+}
42+
43+#[derive(Debug, Args, Clone, Default)]
44+pub struct Global {
45+ /// The repository, as [HOST/]OWNER/NAME, instead of the current directory's.
46+ #[arg(
47+ short = 'R',
48+ long,
49+ global = true,
50+ value_name = "[HOST/]OWNER/NAME",
51+ env = "G1T_REPO"
52+ )]
53+ pub repo: Option<String>,
54+ /// The g1t host, such as g1t.sh or git.example.com.
55+ #[arg(long, global = true, value_name = "HOST")]
56+ pub hostname: Option<String>,
57+ /// Print JSON: every field, or only the comma-separated FIELDS.
58+ #[arg(
59+ long,
60+ global = true,
61+ value_name = "FIELDS",
62+ num_args = 0..=1,
63+ default_missing_value = "",
64+ help_heading = "Output"
65+ )]
66+ pub json: Option<String>,
67+ /// Run a jq expression over the JSON and print what it gives.
68+ #[arg(
69+ long,
70+ global = true,
71+ value_name = "EXPRESSION",
72+ help_heading = "Output"
73+ )]
74+ pub jq: Option<String>,
75+}
76+
77+#[derive(Debug, Subcommand)]
78+pub enum Command {
79+ /// Sign in, sign out, and set git up to use g1t's credentials.
80+ #[command(subcommand)]
81+ Auth(AuthCommand),
82+ /// Repositories: view, clone, create, list.
83+ #[command(subcommand)]
84+ Repo(RepoCommand),
85+ /// Issues: list, view, create, comment, close, reopen, edit.
86+ #[command(subcommand)]
87+ Issue(IssueCommand),
88+ /// Pull requests: list, view, create, check out, diff, checks, review, merge.
89+ #[command(subcommand)]
90+ Pr(PrCommand),
91+ /// Workflow runs: list, view, watch, logs, rerun, cancel.
92+ #[command(subcommand)]
93+ Run(RunCommand),
94+ /// Secrets for workflows and deployments, a repository's or a workspace's.
95+ #[command(subcommand)]
96+ Secret(SecretCommand),
97+ /// Deployments: list and view.
98+ #[command(subcommand)]
99+ Deploy(DeployCommand),
100+ /// A project's logs and errors.
101+ Logs(LogsArgs),
102+ /// What needs you: your pull requests, reviews asked of you, your issues, failing checks.
103+ Status(StatusArgs),
104+ /// Call any API operation or path, with your token.
105+ Api(ApiArgs),
106+ /// Open the repository, an issue or a pull request in the browser.
107+ Browse(BrowseArgs),
108+ /// Shortcuts for commands you type often.
109+ #[command(subcommand)]
110+ Alias(AliasCommand),
111+ /// Print a shell completion script.
112+ Completion(CompletionArgs),
113+ /// Push a local container image to g1t's registry, in chunks.
114+ Push(PushArgs),
115+}
116+
117+// --- auth -------------------------------------------------------------
118+
119+#[derive(Debug, Subcommand)]
120+pub enum AuthCommand {
121+ /// Sign in to a g1t host.
122+ #[command(after_help = "\
123+Without --with-token, g1t shows a code and a link; approve it in the browser, signed in.
124+With --with-token, the token is read from stdin:
125+ g1t auth login --with-token < token.txt")]
126+ Login(LoginArgs),
127+ /// Sign out of an account on a host.
128+ Logout(AccountArgs),
129+ /// Show which accounts are signed in, and whether their tokens work.
130+ Status(AuthStatusArgs),
131+ /// Print the token in use, for another program.
132+ Token(AccountArgs),
133+ /// Make another signed-in account on a host the active one.
134+ Switch(AccountArgs),
135+ /// Make git use g1t's credentials for pushing and fetching over HTTPS.
136+ SetupGit(SetupGitArgs),
137+ /// The git credential helper `setup-git` installs. Git runs it.
138+ #[command(hide = true)]
139+ GitCredential(GitCredentialArgs),
140+}
141+
142+#[derive(Debug, Args)]
143+pub struct LoginArgs {
144+ /// Read a token from stdin instead of approving in the browser.
145+ #[arg(long)]
146+ pub with_token: bool,
147+ /// Keep the token in the config file, not the system keychain.
148+ #[arg(long)]
149+ pub insecure_storage: bool,
150+ /// Do not open the browser; just print the link.
151+ #[arg(long)]
152+ pub no_browser: bool,
153+ /// Also set git up to use g1t's credentials (see `g1t auth setup-git`).
154+ #[arg(long)]
155+ pub git: bool,
156+}
157+
158+#[derive(Debug, Args)]
159+pub struct AccountArgs {
160+ /// The account, when more than one is signed in on the host.
161+ #[arg(short, long)]
162+ pub user: Option<String>,
163+}
164+
165+#[derive(Debug, Args)]
166+pub struct AuthStatusArgs {
167+ /// Print each token too.
168+ #[arg(long)]
169+ pub show_token: bool,
170+}
171+
172+#[derive(Debug, Args)]
173+pub struct SetupGitArgs {
174+ /// Print the git config commands instead of running them.
175+ #[arg(long)]
176+ pub dry_run: bool,
177+}
178+
179+#[derive(Debug, Args)]
180+pub struct GitCredentialArgs {
181+ /// get, store or erase, as git says.
182+ pub operation: String,
183+}
184+
185+// --- repo -------------------------------------------------------------
186+
187+#[derive(Debug, Subcommand)]
188+pub enum RepoCommand {
189+ /// Show a repository.
190+ View(RepoViewArgs),
191+ /// Clone a repository.
192+ Clone(RepoCloneArgs),
193+ /// Make a repository.
194+ Create(RepoCreateArgs),
195+ /// List repositories you can see.
196+ List(RepoListArgs),
197+}
198+
199+#[derive(Debug, Args)]
200+pub struct RepoViewArgs {
201+ /// OWNER/NAME; the current repository when left out.
202+ pub repository: Option<String>,
203+ /// Open it in the browser.
204+ #[arg(short, long)]
205+ pub web: bool,
206+}
207+
208+#[derive(Debug, Args)]
209+pub struct RepoCloneArgs {
210+ /// OWNER/NAME, or NAME for one of your own.
211+ pub repository: String,
212+ /// Where to clone it.
213+ pub directory: Option<String>,
214+ /// Flags for git clone, after --.
215+ #[arg(last = true)]
216+ pub git_flags: Vec<String>,
217+}
218+
219+#[derive(Debug, Args)]
220+pub struct RepoCreateArgs {
221+ /// NAME, or WORKSPACE/NAME.
222+ pub name: Option<String>,
223+ /// Anyone can see it. Repositories are private unless this is given.
224+ #[arg(long, conflicts_with = "private")]
225+ pub public: bool,
226+ /// Only people with a role on it can see it (the default).
227+ #[arg(long)]
228+ pub private: bool,
229+ #[arg(short, long)]
230+ pub description: Option<String>,
231+ /// Clone it once it is made.
232+ #[arg(short, long)]
233+ pub clone: bool,
234+ /// Add it as a remote of this directory's repository and push it there.
235+ #[arg(long, value_name = "DIR")]
236+ pub source: Option<String>,
237+ /// The remote's name, with --source.
238+ #[arg(long, default_value = "origin")]
239+ pub remote: String,
240+}
241+
242+#[derive(Debug, Args)]
243+pub struct RepoListArgs {
244+ /// Only this workspace's.
245+ pub workspace: Option<String>,
246+ /// Only those whose name or description holds this.
247+ #[arg(short, long)]
248+ pub query: Option<String>,
249+ #[arg(short = 'L', long, default_value_t = 30)]
250+ pub limit: usize,
251+}
252+
253+// --- issue ------------------------------------------------------------
254+
255+#[derive(Debug, Subcommand)]
256+pub enum IssueCommand {
257+ /// List issues.
258+ List(IssueListArgs),
259+ /// Show an issue, with its comments.
260+ View(IssueViewArgs),
261+ /// Open an issue.
262+ Create(IssueCreateArgs),
263+ /// Comment on an issue.
264+ Comment(CommentArgs),
265+ /// Close an issue.
266+ Close(IssueCloseArgs),
267+ /// Reopen an issue.
268+ Reopen(ReopenArgs),
269+ /// Change an issue's title, body, labels, assignees or milestone.
270+ Edit(IssueEditArgs),
271+}
272+
273+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
274+pub enum IssueState {
275+ Open,
276+ Closed,
277+ All,
278+}
279+
280+#[derive(Debug, Args)]
281+pub struct IssueListArgs {
282+ #[arg(short, long, value_enum, default_value_t = IssueState::Open)]
283+ pub state: IssueState,
284+ /// Only issues with this label.
285+ #[arg(short, long)]
286+ pub label: Option<String>,
287+ /// Only issues in the milestone of this number.
288+ #[arg(short, long)]
289+ pub milestone: Option<u64>,
290+ /// Only issues assigned to this person (@me for you).
291+ #[arg(short, long)]
292+ pub assignee: Option<String>,
293+ /// Only issues opened by this person (@me for you).
294+ #[arg(short = 'A', long)]
295+ pub author: Option<String>,
296+ #[arg(short = 'L', long, default_value_t = 20)]
297+ pub limit: usize,
298+}
299+
300+#[derive(Debug, Args)]
301+pub struct IssueViewArgs {
302+ /// The issue's number.
303+ pub number: u64,
304+ /// Show its comments too.
305+ #[arg(short, long)]
306+ pub comments: bool,
307+ /// Open it in the browser.
308+ #[arg(short, long)]
309+ pub web: bool,
310+}
311+
312+#[derive(Debug, Args)]
313+pub struct BodyArgs {
314+ /// The text. Markdown.
315+ #[arg(short, long)]
316+ pub body: Option<String>,
317+ /// Read the text from a file, or - for stdin.
318+ #[arg(short = 'F', long, value_name = "FILE")]
319+ pub body_file: Option<String>,
320+}
321+
322+#[derive(Debug, Args)]
323+pub struct IssueCreateArgs {
324+ #[arg(short, long)]
325+ pub title: Option<String>,
326+ #[command(flatten)]
327+ pub body: BodyArgs,
328+ /// A label; repeat for more.
329+ #[arg(short, long)]
330+ pub label: Vec<String>,
331+ /// Someone to assign; repeat for more (@me for you).
332+ #[arg(short, long)]
333+ pub assignee: Vec<String>,
334+ /// A milestone's number.
335+ #[arg(short, long)]
336+ pub milestone: Option<u64>,
337+ /// Open the new-issue page in the browser instead.
338+ #[arg(short, long)]
339+ pub web: bool,
340+}
341+
342+#[derive(Debug, Args)]
343+pub struct CommentArgs {
344+ /// The number; for a pull request, the current branch's when left out.
345+ pub number: Option<u64>,
346+ #[command(flatten)]
347+ pub body: BodyArgs,
348+}
349+
350+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
351+pub enum CloseReason {
352+ Completed,
353+ NotPlanned,
354+}
355+
356+#[derive(Debug, Args)]
357+pub struct IssueCloseArgs {
358+ pub number: u64,
359+ #[arg(short, long, value_enum)]
360+ pub reason: Option<CloseReason>,
361+ /// Leave a comment as it closes.
362+ #[arg(short, long)]
363+ pub comment: Option<String>,
364+}
365+
366+#[derive(Debug, Args)]
367+pub struct ReopenArgs {
368+ pub number: u64,
369+ /// Leave a comment as it reopens.
370+ #[arg(short, long)]
371+ pub comment: Option<String>,
372+}
373+
374+#[derive(Debug, Args)]
375+pub struct IssueEditArgs {
376+ pub number: u64,
377+ #[arg(short, long)]
378+ pub title: Option<String>,
379+ #[command(flatten)]
380+ pub body: BodyArgs,
381+ #[arg(long, value_name = "LABEL")]
382+ pub add_label: Vec<String>,
383+ #[arg(long, value_name = "LABEL")]
384+ pub remove_label: Vec<String>,
385+ #[arg(long, value_name = "LOGIN")]
386+ pub add_assignee: Vec<String>,
387+ #[arg(long, value_name = "LOGIN")]
388+ pub remove_assignee: Vec<String>,
389+ /// A milestone's number.
390+ #[arg(short, long)]
391+ pub milestone: Option<u64>,
392+ /// Take it out of its milestone.
393+ #[arg(long, conflicts_with = "milestone")]
394+ pub remove_milestone: bool,
395+}
396+
397+// --- pr ---------------------------------------------------------------
398+
399+#[derive(Debug, Subcommand)]
400+pub enum PrCommand {
401+ /// List pull requests.
402+ List(PrListArgs),
403+ /// Show a pull request.
404+ View(PrViewArgs),
405+ /// Open a pull request from a branch, or start one for an issue.
406+ Create(PrCreateArgs),
407+ /// Check a pull request's changes out locally.
408+ Checkout(PrCheckoutArgs),
409+ /// Show a pull request's changes as a diff.
410+ Diff(PrDiffArgs),
411+ /// Show a pull request's checks; --watch waits for them.
412+ Checks(PrChecksArgs),
413+ /// Approve a pull request, or ask for changes.
414+ Review(PrReviewArgs),
415+ /// Merge a pull request.
416+ Merge(PrMergeArgs),
417+ /// Mark a draft ready for review, or turn it back into a draft.
418+ Ready(PrReadyArgs),
419+ /// Comment on a pull request.
420+ Comment(CommentArgs),
421+ /// Close a pull request without merging it.
422+ Close(PrSelect),
423+ /// Reopen a closed pull request.
424+ Reopen(PrSelect),
425+}
426+
427+#[derive(Debug, Args)]
428+pub struct PrSelect {
429+ /// The number; the current branch's when left out.
430+ pub number: Option<u64>,
431+}
432+
433+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
434+pub enum PrState {
435+ Open,
436+ Closed,
437+ Merged,
438+ All,
439+}
440+
441+#[derive(Debug, Args)]
442+pub struct PrListArgs {
443+ #[arg(short, long, value_enum, default_value_t = PrState::Open)]
444+ pub state: PrState,
445+ /// Only those merging into this branch.
446+ #[arg(short = 'B', long)]
447+ pub base: Option<String>,
448+ #[arg(short, long)]
449+ pub label: Option<String>,
450+ /// Only those opened by this person (@me for you).
451+ #[arg(short = 'A', long)]
452+ pub author: Option<String>,
453+ /// Only drafts.
454+ #[arg(short, long)]
455+ pub draft: bool,
456+ #[arg(short = 'L', long, default_value_t = 20)]
457+ pub limit: usize,
458+}
459+
460+#[derive(Debug, Args)]
461+pub struct PrViewArgs {
462+ /// The number; the current branch's when left out.
463+ pub number: Option<u64>,
464+ #[arg(short, long)]
465+ pub comments: bool,
466+ #[arg(short, long)]
467+ pub web: bool,
468+}
469+
470+#[derive(Debug, Args)]
471+pub struct PrCreateArgs {
472+ #[arg(short, long)]
473+ pub title: Option<String>,
474+ #[command(flatten)]
475+ pub body: BodyArgs,
476+ /// The branch to merge into; the default branch when left out.
477+ #[arg(short = 'B', long)]
478+ pub base: Option<String>,
479+ /// The branch with the changes; the current one when left out.
480+ #[arg(short = 'H', long)]
481+ pub head: Option<String>,
482+ /// The issue it is for. Without a branch, starts a draft in a working copy of its own.
483+ #[arg(short, long)]
484+ pub issue: Option<u64>,
485+ /// Take the title and body from the branch's commits.
486+ #[arg(short, long)]
487+ pub fill: bool,
488+ /// Open it as a draft.
489+ #[arg(short, long)]
490+ pub draft: bool,
491+ /// Do not push the branch first.
492+ #[arg(long)]
493+ pub no_push: bool,
494+}
495+
496+#[derive(Debug, Args)]
497+pub struct PrCheckoutArgs {
498+ pub number: u64,
499+ /// The local branch's name; pr-NUMBER when left out.
500+ #[arg(short, long)]
501+ pub branch: Option<String>,
502+ /// Reset the local branch to the pull request's head even if it has other commits.
503+ #[arg(short, long)]
504+ pub force: bool,
505+}
506+
507+#[derive(Debug, Args)]
508+pub struct PrDiffArgs {
509+ pub number: Option<u64>,
510+ /// Only the changed files' names.
511+ #[arg(long)]
512+ pub name_only: bool,
513+ /// Color: auto, always or never.
514+ #[arg(long, default_value = "auto")]
515+ pub color: String,
516+}
517+
518+#[derive(Debug, Args)]
519+pub struct PrChecksArgs {
520+ pub number: Option<u64>,
521+ /// Wait until every check has finished.
522+ #[arg(short, long)]
523+ pub watch: bool,
524+ /// With --watch, stop at the first failing check.
525+ #[arg(long, requires = "watch")]
526+ pub fail_fast: bool,
527+ /// Seconds between looks, with --watch.
528+ #[arg(short, long, default_value_t = 10)]
529+ pub interval: u64,
530+}
531+
532+#[derive(Debug, Args)]
533+#[command(group(clap::ArgGroup::new("verdict").required(true).args(["approve", "request_changes", "comment"])))]
534+pub struct PrReviewArgs {
535+ pub number: Option<u64>,
536+ #[arg(short, long)]
537+ pub approve: bool,
538+ #[arg(short, long)]
539+ pub request_changes: bool,
540+ /// Comment without approving or asking for changes.
541+ #[arg(short, long)]
542+ pub comment: bool,
543+ #[command(flatten)]
544+ pub body: BodyArgs,
545+}
546+
547+#[derive(Debug, Args)]
548+pub struct PrMergeArgs {
549+ pub number: Option<u64>,
550+ /// Leave the issue it is for open.
551+ #[arg(long)]
552+ pub keep_issue_open: bool,
553+ /// Merge past failing required checks and rules (needs the Admin role).
554+ #[arg(long)]
555+ pub admin: bool,
556+ /// Delete the local and remote branch afterwards.
557+ #[arg(short, long)]
558+ pub delete_branch: bool,
559+}
560+
561+#[derive(Debug, Args)]
562+pub struct PrReadyArgs {
563+ pub number: Option<u64>,
564+ /// What changed and why; it becomes the description. The current one when left out.
565+ #[arg(short, long)]
566+ pub summary: Option<String>,
567+ /// Turn it back into a draft.
568+ #[arg(long)]
569+ pub undo: bool,
570+}
571+
572+// --- run --------------------------------------------------------------
573+
574+#[derive(Debug, Subcommand)]
575+pub enum RunCommand {
576+ /// List workflow runs, newest first.
577+ List(RunListArgs),
578+ /// Show a run's jobs and steps.
579+ View(RunViewArgs),
580+ /// Follow a run until it finishes; exits 1 if it fails.
581+ Watch(RunWatchArgs),
582+ /// Print a run's logs; --failed prints only the failing steps' tails.
583+ Logs(RunLogsArgs),
584+ /// Run a run again: all of it, its failed jobs, or one job.
585+ Rerun(RunRerunArgs),
586+ /// Cancel a run.
587+ Cancel(RunSelect),
588+}
589+
590+#[derive(Debug, Args)]
591+pub struct RunSelect {
592+ /// The run's id (run_…) or number; the current branch's latest when left out.
593+ pub run: Option<String>,
594+}
595+
596+#[derive(Debug, Args)]
597+pub struct RunListArgs {
598+ /// Only this workflow's: its file name or id.
599+ #[arg(short, long)]
600+ pub workflow: Option<String>,
601+ #[arg(short, long)]
602+ pub branch: Option<String>,
603+ /// Only runs for this event: push, pull_request, schedule...
604+ #[arg(short, long)]
605+ pub event: Option<String>,
606+ /// Only runs of this commit.
607+ #[arg(short, long)]
608+ pub commit: Option<String>,
609+ /// Only runs for this pull request.
610+ #[arg(long)]
611+ pub pr: Option<u64>,
612+ #[arg(short = 'L', long, default_value_t = 20)]
613+ pub limit: usize,
614+}
615+
616+#[derive(Debug, Args)]
617+pub struct RunViewArgs {
618+ pub run: Option<String>,
619+ /// Show this job's steps only.
620+ #[arg(short, long)]
621+ pub job: Option<String>,
622+ /// Print the log too.
623+ #[arg(long)]
624+ pub log: bool,
625+ /// Print the failing steps' logs too.
626+ #[arg(long)]
627+ pub log_failed: bool,
628+ #[arg(short, long)]
629+ pub web: bool,
630+ /// Exit 1 when the run failed.
631+ #[arg(long)]
632+ pub exit_status: bool,
633+}
634+
635+#[derive(Debug, Args)]
636+pub struct RunWatchArgs {
637+ pub run: Option<String>,
638+ /// Seconds between looks.
639+ #[arg(short, long, default_value_t = 3)]
640+ pub interval: u64,
641+}
642+
643+#[derive(Debug, Args)]
644+pub struct RunLogsArgs {
645+ pub run: Option<String>,
646+ /// Only this job's (its id, key or name).
647+ #[arg(short, long)]
648+ pub job: Option<String>,
649+ /// Only the failing steps, and only their last lines.
650+ #[arg(short, long)]
651+ pub failed: bool,
652+ /// How many lines of each failing step to print, with --failed.
653+ #[arg(short = 'n', long, default_value_t = 60)]
654+ pub tail: usize,
655+}
656+
657+#[derive(Debug, Args)]
658+pub struct RunRerunArgs {
659+ pub run: Option<String>,
660+ /// Only the jobs that failed.
661+ #[arg(long)]
662+ pub failed: bool,
663+ /// Only this job (its id).
664+ #[arg(short, long, conflicts_with = "failed")]
665+ pub job: Option<String>,
666+ /// With debug logging.
667+ #[arg(short, long)]
668+ pub debug: bool,
669+}
670+
671+// --- secret -----------------------------------------------------------
672+
673+#[derive(Debug, Subcommand)]
674+pub enum SecretCommand {
675+ /// List secrets' names (never their values).
676+ List(SecretScope),
677+ /// Set a secret. The value is read from stdin when not given.
678+ Set(SecretSetArgs),
679+ /// Delete a secret.
680+ Delete(SecretDeleteArgs),
681+}
682+
683+#[derive(Debug, Args)]
684+pub struct SecretScope {
685+ /// A workspace's secrets instead of the repository's.
686+ #[arg(short, long, value_name = "WORKSPACE")]
687+ pub workspace: Option<String>,
688+ /// Only the value for this environment (production, preview...).
689+ #[arg(short, long, value_name = "ENVIRONMENT")]
690+ pub env: Option<String>,
691+}
692+
693+#[derive(Debug, Args)]
694+pub struct SecretSetArgs {
695+ /// The secret's name; uppercased.
696+ pub name: String,
697+ /// The value. Read from stdin when left out, so it stays out of your shell's history.
698+ #[arg(short, long)]
699+ pub body: Option<String>,
700+ #[command(flatten)]
701+ pub scope: SecretScope,
702+ /// Who can read it: workflows, deployments, or both (repeat).
703+ #[arg(long, value_name = "WHO")]
704+ pub available_to: Vec<String>,
705+ /// Broker it: jobs get a stand-in, and g1t fills the value in only on requests to this host. Repeat for more.
706+ #[arg(long, value_name = "HOST")]
707+ pub brokered_host: Vec<String>,
708+ /// A note for whoever reads the list.
709+ #[arg(long)]
710+ pub note: Option<String>,
711+}
712+
713+#[derive(Debug, Args)]
714+pub struct SecretDeleteArgs {
715+ pub name: String,
716+ #[command(flatten)]
717+ pub scope: SecretScope,
718+}
719+
720+// --- deploy -----------------------------------------------------------
721+
722+#[derive(Debug, Subcommand)]
723+pub enum DeployCommand {
724+ /// List deployments, newest first.
725+ List(DeployListArgs),
726+ /// Show a deployment and its statuses.
727+ View(DeployViewArgs),
728+}
729+
730+#[derive(Debug, Args)]
731+pub struct DeployListArgs {
732+ #[arg(short, long)]
733+ pub env: Option<String>,
734+ /// queued, in_progress, success, failure, error or inactive.
735+ #[arg(short, long)]
736+ pub state: Option<String>,
737+ /// api, actions or g1t_page.
738+ #[arg(long)]
739+ pub source: Option<String>,
740+ #[arg(short = 'L', long, default_value_t = 20)]
741+ pub limit: usize,
742+}
743+
744+#[derive(Debug, Args)]
745+pub struct DeployViewArgs {
746+ pub id: String,
747+ #[arg(short, long)]
748+ pub web: bool,
749+}
750+
751+// --- logs -------------------------------------------------------------
752+
753+#[derive(Debug, Args)]
754+pub struct LogsArgs {
755+ /// The project; the current repository's when left out.
756+ #[arg(short, long)]
757+ pub project: Option<String>,
758+ /// Grouped errors instead of log lines.
759+ #[arg(long)]
760+ pub errors: bool,
761+ /// Lines holding all of these words, or a "quoted phrase".
762+ #[arg(short, long)]
763+ pub query: Option<String>,
764+ /// Levels: trace, debug, info, warn, error, fatal (comma-separated).
765+ #[arg(short, long)]
766+ pub level: Option<String>,
767+ #[arg(short, long)]
768+ pub env: Option<String>,
769+ #[arg(long)]
770+ pub service: Option<String>,
771+ /// How far back: 15m, 2h, 1d, or a time like 2026-10-12T09:00:00Z.
772+ #[arg(short, long)]
773+ pub since: Option<String>,
774+ /// With --errors: open, resolved or ignored.
775+ #[arg(long)]
776+ pub state: Option<String>,
777+ /// Keep printing new lines as they come.
778+ #[arg(short, long, conflicts_with = "errors")]
779+ pub follow: bool,
780+ #[arg(short = 'L', long, default_value_t = 50)]
781+ pub limit: usize,
782+}
783+
784+// --- status, api, browse ---------------------------------------------
785+
786+#[derive(Debug, Args)]
787+pub struct StatusArgs {}
788+
789+#[derive(Debug, Args)]
790+#[command(after_help = "\
791+ENDPOINT is an operation's name (list_issues) or a path (/repos/{owner}/{name}/issues).
792+{owner}, {name}, {repo} and {workspace} are filled in from the current repository,
793+and an operation's other path parameters from -f/-F fields of the same name.
794+
795+Examples:
796+ g1t api list_issues -f state=closed
797+ g1t api /user --jq .username
798+ g1t api create_issue -f title='Broken link' -f labels[]=docs
799+ g1t api list_workflow_runs --paginate --jq '.[].conclusion'
800+ g1t api --list issue")]
801+pub struct ApiArgs {
802+ /// An operation's name, or a path.
803+ pub endpoint: Option<String>,
804+ /// List the operations, or those matching this word.
805+ #[arg(long, value_name = "QUERY", num_args = 0..=1, default_missing_value = "")]
806+ pub list: Option<String>,
807+ /// The HTTP method: GET unless fields are given, then POST.
808+ #[arg(short = 'X', long)]
809+ pub method: Option<String>,
810+ /// A string field, key=value. key[]=value adds to a list.
811+ #[arg(short = 'f', long = "raw-field", value_name = "KEY=VALUE")]
812+ pub raw_field: Vec<String>,
813+ /// A typed field: true, false, null and numbers become JSON; @file reads a file.
814+ #[arg(short = 'F', long = "field", value_name = "KEY=VALUE")]
815+ pub field: Vec<String>,
816+ /// A request header, key:value.
817+ #[arg(short = 'H', long = "header", value_name = "KEY:VALUE")]
818+ pub header: Vec<String>,
819+ /// The request body, from a file or - for stdin.
820+ #[arg(long, value_name = "FILE")]
821+ pub input: Option<String>,
822+ /// Follow next pages until there are none.
823+ #[arg(long)]
824+ pub paginate: bool,
825+ /// With --paginate, put every page's items into one array.
826+ #[arg(long, requires = "paginate")]
827+ pub slurp: bool,
828+ /// Print the status line and headers too.
829+ #[arg(short, long)]
830+ pub include: bool,
831+ /// Print nothing on success.
832+ #[arg(long)]
833+ pub silent: bool,
834+}
835+
836+#[derive(Debug, Args)]
837+pub struct BrowseArgs {
838+ /// An issue or pull request number, a file path (path:line), or a commit.
839+ pub target: Option<String>,
840+ /// The branch to show files on.
841+ #[arg(short, long)]
842+ pub branch: Option<String>,
843+ #[arg(short, long)]
844+ pub settings: bool,
845+ #[arg(long)]
846+ pub issues: bool,
847+ #[arg(long)]
848+ pub pulls: bool,
849+ #[arg(long)]
850+ pub actions: bool,
851+ /// Print the address instead of opening it.
852+ #[arg(short, long)]
853+ pub no_browser: bool,
854+}
855+
856+// --- alias, completion, push -----------------------------------------
857+
858+#[derive(Debug, Subcommand)]
859+pub enum AliasCommand {
860+ /// Make an alias: g1t alias set co 'pr checkout'. Start it with ! to run it through the shell.
861+ Set(AliasSetArgs),
862+ /// List aliases.
863+ List,
864+ /// Delete an alias.
865+ Delete(AliasDeleteArgs),
866+}
867+
868+#[derive(Debug, Args)]
869+pub struct AliasSetArgs {
870+ pub name: String,
871+ /// What it expands to. $1, $2... take the arguments after it.
872+ pub expansion: String,
873+}
874+
875+#[derive(Debug, Args)]
876+pub struct AliasDeleteArgs {
877+ pub name: String,
878+}
879+
880+#[derive(Debug, Args)]
881+pub struct CompletionArgs {
882+ #[arg(value_enum)]
883+ pub shell: clap_complete::Shell,
884+}
885+
886+#[derive(Debug, Args)]
887+#[command(after_help = "\
888+The token is the first of: --token-stdin, G1T_TOKEN, the account `g1t auth login`
889+signed in for the registry's host, or what `docker login g1t.sh` stored.")]
890+pub struct PushArgs {
891+ /// The local image, [REGISTRY/]NAME[:TAG].
892+ pub image: Option<String>,
893+ /// Where to push, when the image's own name is not an address on g1t.sh. The tag defaults to latest.
894+ #[arg(long = "as", value_name = "ADDRESS")]
895+ pub target: Option<String>,
896+ /// How much of a layer each request carries: 5MB to 95MB.
897+ #[arg(long, value_name = "SIZE", default_value = "90MB", value_parser = parse_chunk)]
898+ pub chunk_size: u64,
899+ /// Read the g1t token from stdin.
900+ #[arg(long)]
901+ pub token_stdin: bool,
902+ /// Push a tarball `docker save` wrote, instead of asking docker for the image.
903+ #[arg(long, value_name = "FILE", conflicts_with = "image")]
904+ pub archive: Option<PathBuf>,
905+}
906+
907+fn parse_chunk(text: &str) -> Result<u64, String> {
908+ crate::chunk::check_chunk(crate::chunk::parse_size(text)?)
909+}
+330−0
1+//! Calls to the g1t API.
2+
3+use serde_json::{Map, Value};
4+use std::collections::BTreeMap;
5+use std::io::Read;
6+use std::time::Duration;
7+
8+use crate::auth::Host;
9+use crate::error::{Error, Result};
10+use crate::ops::{self, Operation};
11+use crate::repo::Repo;
12+
13+pub struct Client {
14+ pub host: Host,
15+ token: Option<String>,
16+ agent: ureq::Agent,
17+ debug: bool,
18+}
19+
20+/// What came back from a raw request, for `g1t api`.
21+pub struct Reply {
22+ pub status: u16,
23+ pub headers: Vec<(String, String)>,
24+ pub text: String,
25+}
26+
27+impl Reply {
28+ pub fn json(&self) -> Option<Value> {
29+ if self.text.trim().is_empty() {
30+ return Some(Value::Null);
31+ }
32+ serde_json::from_str(&self.text).ok()
33+ }
34+
35+ pub fn header(&self, name: &str) -> Option<&str> {
36+ self.headers
37+ .iter()
38+ .find(|(k, _)| k.eq_ignore_ascii_case(name))
39+ .map(|(_, v)| v.as_str())
40+ }
41+}
42+
43+/// Encodes one segment of a path: a branch's `/` stays inside the segment.
44+pub fn encode_segment(segment: &str) -> String {
45+ let mut out = String::new();
46+ for byte in segment.bytes() {
47+ match byte {
48+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
49+ out.push(byte as char)
50+ }
51+ _ => out.push_str(&format!("%{byte:02X}")),
52+ }
53+ }
54+ out
55+}
56+
57+impl Client {
58+ pub fn new(host: Host, token: Option<String>) -> Client {
59+ let agent = ureq::AgentBuilder::new()
60+ .timeout_connect(Duration::from_secs(15))
61+ .timeout_read(Duration::from_secs(120))
62+ .user_agent(&format!("g1t-cli/{}", env!("CARGO_PKG_VERSION")))
63+ .redirects(5)
64+ .build();
65+ let debug = std::env::var("G1T_DEBUG").is_ok_and(|v| !v.is_empty() && v != "0");
66+ Client {
67+ host,
68+ token,
69+ agent,
70+ debug,
71+ }
72+ }
73+
74+ pub fn has_token(&self) -> bool {
75+ self.token.is_some()
76+ }
77+
78+ /// Starts a call to the operation `id`.
79+ pub fn op(&self, id: &str) -> Call<'_> {
80+ Call {
81+ client: self,
82+ op: ops::get(id),
83+ path: BTreeMap::new(),
84+ query: Vec::new(),
85+ body: None,
86+ }
87+ }
88+
89+ /// A request to `path` (under the API), answered with whatever status.
90+ pub fn raw(
91+ &self,
92+ method: &str,
93+ path_or_url: &str,
94+ query: &[(String, String)],
95+ headers: &[(String, String)],
96+ body: Option<Vec<u8>>,
97+ ) -> Result<Reply> {
98+ let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
99+ path_or_url.to_owned()
100+ } else {
101+ format!("{}/{}", self.host.api, path_or_url.trim_start_matches('/'))
102+ };
103+ let mut request = self
104+ .agent
105+ .request(method, &url)
106+ .set("accept", "application/json");
107+ if let Some(token) = &self.token {
108+ request = request.set("authorization", &format!("Bearer {token}"));
109+ }
110+ for (k, v) in query {
111+ request = request.query(k, v);
112+ }
113+ let has_type = headers
114+ .iter()
115+ .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
116+ for (k, v) in headers {
117+ request = request.set(k, v);
118+ }
119+ if self.debug {
120+ eprintln!("* {method} {}", request.url());
121+ }
122+ let result = match body {
123+ Some(bytes) => {
124+ if !has_type {
125+ request = request.set("content-type", "application/json");
126+ }
127+ request.send_bytes(&bytes)
128+ }
129+ None => request.call(),
130+ };
131+ let response = match result {
132+ Ok(r) => r,
133+ Err(ureq::Error::Status(_, r)) => r,
134+ Err(ureq::Error::Transport(t)) => {
135+ return Err(Error::failed(format!(
136+ "Could not reach {}: {t}",
137+ self.host.api
138+ )));
139+ }
140+ };
141+ let status = response.status();
142+ let headers = response
143+ .headers_names()
144+ .into_iter()
145+ .filter_map(|name| response.header(&name).map(|v| (name.clone(), v.to_owned())))
146+ .collect();
147+ let mut text = String::new();
148+ response
149+ .into_reader()
150+ .take(256 * 1024 * 1024)
151+ .read_to_string(&mut text)
152+ .map_err(|e| {
153+ Error::failed(format!(
154+ "Could not read the answer from {}: {e}",
155+ self.host.api
156+ ))
157+ })?;
158+ if self.debug {
159+ eprintln!("* {status}");
160+ }
161+ Ok(Reply {
162+ status,
163+ headers,
164+ text,
165+ })
166+ }
167+
168+ /// A request whose answer must be a success, as JSON.
169+ pub fn send(
170+ &self,
171+ method: &str,
172+ path: &str,
173+ query: &[(String, String)],
174+ body: Option<&Value>,
175+ ) -> Result<Value> {
176+ let bytes = body.map(|b| serde_json::to_vec(b).expect("json"));
177+ let reply = self.raw(method, path, query, &[], bytes)?;
178+ if !(200..300).contains(&reply.status) {
179+ return Err(self.api_error(&reply));
180+ }
181+ Ok(reply.json().unwrap_or(Value::String(reply.text)))
182+ }
183+
184+ /// One line saying why the API refused.
185+ pub fn api_error(&self, reply: &Reply) -> Error {
186+ let body = reply.json().unwrap_or(Value::Null);
187+ let message = body["error"]["message"]
188+ .as_str()
189+ .or_else(|| body["message"].as_str())
190+ .map(str::to_owned)
191+ .unwrap_or_else(|| {
192+ let text = reply.text.trim();
193+ if text.is_empty() || text.starts_with('<') {
194+ format!("{} answered HTTP {}", self.host.api, reply.status)
195+ } else {
196+ text.lines().next().unwrap_or_default().to_owned()
197+ }
198+ });
199+ let message = message.trim_end_matches('.').to_owned();
200+ match reply.status {
201+ 401 if self.token.is_none() => Error::auth(format!(
202+ "{message}. Sign in with `g1t auth login{}`, or set G1T_TOKEN.",
203+ self.hostname_flag()
204+ )),
205+ 401 => Error::auth(format!(
206+ "{message}. The token for {} was refused: sign in again with `g1t auth login{}`.",
207+ self.host.name,
208+ self.hostname_flag()
209+ )),
210+ 403 => match body["error"]["needed_scope"].as_str() {
211+ Some(scope) => Error::failed(format!(
212+ "{message}. The token needs the `{scope}` scope: make one with it at {}/settings/tokens.",
213+ self.host.site
214+ )),
215+ None => Error::failed(format!("{message}.")),
216+ },
217+ 404 if self.token.is_none() => Error::failed(format!(
218+ "{message}. If it is private, sign in first: `g1t auth login{}`.",
219+ self.hostname_flag()
220+ )),
221+ _ => Error::failed(format!("{message}.")),
222+ }
223+ }
224+
225+ fn hostname_flag(&self) -> String {
226+ if self.host.name == crate::config::DEFAULT_HOST {
227+ String::new()
228+ } else {
229+ format!(" --hostname {}", self.host.name)
230+ }
231+ }
232+}
233+
234+/// One call to an operation, its path filled in as it is built.
235+pub struct Call<'a> {
236+ client: &'a Client,
237+ op: &'static Operation,
238+ path: BTreeMap<&'static str, String>,
239+ query: Vec<(String, String)>,
240+ body: Option<Map<String, Value>>,
241+}
242+
243+impl<'a> Call<'a> {
244+ pub fn arg(mut self, name: &str, value: impl ToString) -> Self {
245+ let param = self
246+ .op
247+ .path_params()
248+ .find(|p| p.name == name)
249+ .unwrap_or_else(|| panic!("{} has no path parameter {name}", self.op.id));
250+ self.path.insert(param.name, value.to_string());
251+ self
252+ }
253+
254+ /// The repository's owner and name.
255+ pub fn repo(self, repo: &Repo) -> Self {
256+ self.arg("owner", &repo.owner).arg("name", &repo.name)
257+ }
258+
259+ pub fn query(mut self, name: &str, value: impl ToString) -> Self {
260+ self.query.push((name.to_owned(), value.to_string()));
261+ self
262+ }
263+
264+ pub fn query_opt<T: ToString>(self, name: &str, value: Option<T>) -> Self {
265+ match value {
266+ Some(v) => self.query(name, v),
267+ None => self,
268+ }
269+ }
270+
271+ pub fn field(mut self, name: &str, value: impl Into<Value>) -> Self {
272+ self.body
273+ .get_or_insert_with(Map::new)
274+ .insert(name.to_owned(), value.into());
275+ self
276+ }
277+
278+ pub fn field_opt<T: Into<Value>>(self, name: &str, value: Option<T>) -> Self {
279+ match value {
280+ Some(v) => self.field(name, v),
281+ None => self,
282+ }
283+ }
284+
285+ pub fn path(&self) -> String {
286+ let mut path = self.op.path.to_owned();
287+ for p in self.op.path_params() {
288+ let value = self
289+ .path
290+ .get(p.name)
291+ .unwrap_or_else(|| panic!("{} needs {}", self.op.id, p.name));
292+ path = path.replace(&format!("{{{}}}", p.name), &encode_segment(value));
293+ }
294+ path
295+ }
296+
297+ pub fn send(self) -> Result<Value> {
298+ let path = self.path();
299+ let body = match (self.body, self.op.method) {
300+ (Some(map), _) => Some(Value::Object(map)),
301+ (None, "POST" | "PUT" | "PATCH") => Some(Value::Object(Map::new())),
302+ (None, _) => None,
303+ };
304+ self.client
305+ .send(self.op.method, &path, &self.query, body.as_ref())
306+ }
307+}
308+
309+#[cfg(test)]
310+mod tests {
311+ use super::*;
312+
313+ #[test]
314+ fn segments_are_encoded() {
315+ assert_eq!(encode_segment("feature/x y"), "feature%2Fx%20y");
316+ assert_eq!(encode_segment("NPM_TOKEN"), "NPM_TOKEN");
317+ }
318+
319+ #[test]
320+ fn calls_fill_their_path() {
321+ let client = Client::new(Host::hosted(), None);
322+ let repo = Repo {
323+ host: "g1t.sh".into(),
324+ owner: "acme".into(),
325+ name: "web".into(),
326+ };
327+ let call = client.op("get_issue").repo(&repo).arg("number", 12);
328+ assert_eq!(call.path(), "/repos/acme/web/issues/12");
329+ }
330+}
+228−0
1+//! `g1t alias`: shortcuts, kept in config.json and expanded before the
2+//! command line is read.
3+
4+use clap::CommandFactory;
5+
6+use crate::cli::{AliasCommand, Cli};
7+use crate::config::Config;
8+use crate::error::{Error, Kind, Result};
9+
10+pub enum Expanded {
11+ Args(Vec<String>),
12+ /// A `!` alias ran in the shell, with this exit code.
13+ Ran(i32),
14+}
15+
16+fn builtin(name: &str) -> bool {
17+ Cli::command()
18+ .get_subcommands()
19+ .any(|c| c.get_name() == name || c.get_all_aliases().any(|a| a == name))
20+ || matches!(name, "help")
21+}
22+
23+/// Splits `text` into words the way a shell would, quotes included.
24+pub fn split(text: &str) -> Result<Vec<String>> {
25+ let mut words = Vec::new();
26+ let mut word = String::new();
27+ let mut in_word = false;
28+ let mut chars = text.chars();
29+ let mut quote: Option<char> = None;
30+ while let Some(c) = chars.next() {
31+ match (quote, c) {
32+ (Some(q), c) if c == q => quote = None,
33+ (Some('"'), '\\') => {
34+ if let Some(next) = chars.next() {
35+ word.push(next);
36+ }
37+ }
38+ (Some(_), c) => word.push(c),
39+ (None, '\'' | '"') => {
40+ quote = Some(c);
41+ in_word = true;
42+ }
43+ (None, '\\') => {
44+ if let Some(next) = chars.next() {
45+ word.push(next);
46+ in_word = true;
47+ }
48+ }
49+ (None, c) if c.is_whitespace() => {
50+ if in_word {
51+ words.push(std::mem::take(&mut word));
52+ in_word = false;
53+ }
54+ }
55+ (None, c) => {
56+ word.push(c);
57+ in_word = true;
58+ }
59+ }
60+ }
61+ if quote.is_some() {
62+ return Err(Error::usage(format!("Unclosed quote in `{text}`.")));
63+ }
64+ if in_word {
65+ words.push(word);
66+ }
67+ Ok(words)
68+}
69+
70+/// Puts `args` into `expansion`'s `$1`, `$2`...; the ones not used go after.
71+pub fn substitute(expansion: &[String], args: &[String]) -> Vec<String> {
72+ let mut used = vec![false; args.len()];
73+ let mut out: Vec<String> = expansion
74+ .iter()
75+ .map(|word| {
76+ let mut word = word.clone();
77+ for (i, arg) in args.iter().enumerate().rev() {
78+ let marker = format!("${}", i + 1);
79+ if word.contains(&marker) {
80+ word = word.replace(&marker, arg);
81+ used[i] = true;
82+ }
83+ }
84+ word
85+ })
86+ .collect();
87+ out.extend(
88+ args.iter()
89+ .zip(&used)
90+ .filter(|(_, u)| !**u)
91+ .map(|(a, _)| a.clone()),
92+ );
93+ out
94+}
95+
96+pub fn expand(args: Vec<String>) -> Result<Expanded> {
97+ let Some(first) = args.get(1) else {
98+ return Ok(Expanded::Args(args));
99+ };
100+ if first.starts_with('-') || builtin(first) {
101+ return Ok(Expanded::Args(args));
102+ }
103+ let config = Config::load()?;
104+ let Some(expansion) = config.aliases.get(first) else {
105+ return Ok(Expanded::Args(args));
106+ };
107+ let rest = &args[2..];
108+ if let Some(shell_line) = expansion.strip_prefix('!') {
109+ let shell_args = substitute(&[shell_line.to_owned()], rest);
110+ let line = shell_args.join(" ");
111+ let status = std::process::Command::new("sh")
112+ .args(["-c", &line])
113+ .status()
114+ .map_err(|e| Error::failed(format!("Could not run the alias {first} in sh: {e}")))?;
115+ return Ok(Expanded::Ran(status.code().unwrap_or(1)));
116+ }
117+ let words = split(expansion)?;
118+ let mut out = vec![args[0].clone()];
119+ out.extend(substitute(&words, rest));
120+ Ok(Expanded::Args(out))
121+}
122+
123+pub fn run(command: AliasCommand) -> Result<()> {
124+ let mut config = Config::load()?;
125+ match command {
126+ AliasCommand::Set(args) => {
127+ if builtin(&args.name) {
128+ return Err(Error::usage(format!(
129+ "`{}` is already a g1t command.",
130+ args.name
131+ )));
132+ }
133+ if !args.expansion.starts_with('!') {
134+ let words = split(&args.expansion)?;
135+ match words.first() {
136+ Some(first) if builtin(first) || config.aliases.contains_key(first) => {}
137+ _ => {
138+ return Err(Error::usage(format!(
139+ "`{}` does not start with a g1t command. Start it with ! to run it in the shell.",
140+ args.expansion
141+ )));
142+ }
143+ }
144+ }
145+ let replaced = config
146+ .aliases
147+ .insert(args.name.clone(), args.expansion.clone())
148+ .is_some();
149+ config.save()?;
150+ eprintln!(
151+ "✓ {} {} now runs `g1t {}`.",
152+ if replaced { "Changed:" } else { "Added:" },
153+ args.name,
154+ args.expansion
155+ );
156+ Ok(())
157+ }
158+ AliasCommand::List => {
159+ if config.aliases.is_empty() {
160+ eprintln!("No aliases. Add one: g1t alias set co 'pr checkout'");
161+ return Ok(());
162+ }
163+ for (name, expansion) in &config.aliases {
164+ println!("{name}: {expansion}");
165+ }
166+ Ok(())
167+ }
168+ AliasCommand::Delete(args) => {
169+ if config.aliases.remove(&args.name).is_none() {
170+ return Err(Error {
171+ kind: Kind::Failed,
172+ message: format!("No alias {}.", args.name),
173+ silent: false,
174+ });
175+ }
176+ config.save()?;
177+ eprintln!("✓ Deleted the alias {}.", args.name);
178+ Ok(())
179+ }
180+ }
181+}
182+
183+#[cfg(test)]
184+mod tests {
185+ use super::*;
186+
187+ fn v(words: &[&str]) -> Vec<String> {
188+ words.iter().map(|s| s.to_string()).collect()
189+ }
190+
191+ #[test]
192+ fn words_split_like_a_shell() {
193+ assert_eq!(split("pr checkout").unwrap(), v(&["pr", "checkout"]));
194+ assert_eq!(
195+ split(r#"issue list --label "good first issue""#).unwrap(),
196+ v(&["issue", "list", "--label", "good first issue"])
197+ );
198+ assert_eq!(
199+ split("api '/repos/{owner}/{name}'").unwrap(),
200+ v(&["api", "/repos/{owner}/{name}"])
201+ );
202+ assert_eq!(split(r"a\ b c").unwrap(), v(&["a b", "c"]));
203+ assert_eq!(split("x ''").unwrap(), v(&["x", ""]));
204+ assert!(split("'open").is_err());
205+ }
206+
207+ #[test]
208+ fn arguments_fill_their_places() {
209+ assert_eq!(
210+ substitute(&v(&["pr", "checkout"]), &v(&["12"])),
211+ v(&["pr", "checkout", "12"])
212+ );
213+ assert_eq!(
214+ substitute(
215+ &v(&["issue", "list", "--label", "$1"]),
216+ &v(&["bug", "-L", "5"])
217+ ),
218+ v(&["issue", "list", "--label", "bug", "-L", "5"])
219+ );
220+ }
221+
222+ #[test]
223+ fn commands_are_builtin() {
224+ assert!(builtin("pr"));
225+ assert!(builtin("help"));
226+ assert!(!builtin("co"));
227+ }
228+}
+604−0
Loading the diff…
+536−0
Loading the diff…
+132−0
Loading the diff…
+126−0
Loading the diff…
+500−0
Loading the diff…
+279−0
Loading the diff…
+69−0
Loading the diff…
+969−0
Loading the diff…
+181−0
Loading the diff…
+616−0
Loading the diff…
+239−0
Loading the diff…
+155−0
Loading the diff…
+155−0
Loading the diff…
+164−0
Loading the diff…
+9−3
Loading the diff…
+98−0
Loading the diff…
+90−0
Loading the diff…
+106−0
Loading the diff…
+71−278
Loading the diff…
+369−0
Loading the diff…
+264−0
Loading the diff…
+6912−0
A large diff (6,912 lines) is not shown until you ask for it.
+404−0
Loading the diff…
+153−0
Loading the diff…
+226−0
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.