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.

478 linesCodeBlameRaw
1//! The command reference in the docs, `apps/docs/src/content/docs/reference/cli.md`,
2//! is written from the command tree itself. A test fails when it is out of
3//! date; regenerate it with
4//!
5//! ```sh
6//! G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference
7//! ```
8
9use clap::{Arg, ArgAction, Command};
10use std::fmt::Write as _;
11
12const HEAD: &str = "---
13title: CLI commands
14description: Every g1t command with its arguments and flags, written from the CLI's own definitions.
15---
16
17<!-- Generated from crates/g1t by `G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference`. Do not edit by hand. -->
18
19Every command of the [g1t CLI](/guides/cli/), with its arguments and flags.
20`g1t <command> --help` prints the same at a terminal. Each command that talks
21to the API names the operations it calls, which link to the
22[API reference](/reference/api/); [`g1t api`](#g1t-api) calls any other.
23
24## Flags every command takes
25
26| Flag | |
27| --- | --- |
28";
29
30/// Help text as a sentence: clap drops the full stop doc comments end with.
31fn sentence(text: &str) -> String {
32 let text = text.trim();
33 if text.is_empty() || text.ends_with(['.', ':', '?', '!']) {
34 text.to_owned()
35 } else {
36 format!("{text}.")
37 }
38}
39
40fn slug(text: &str) -> String {
41 let mut out = String::new();
42 for c in text.to_lowercase().chars() {
43 if c.is_ascii_alphanumeric() {
44 out.push(c);
45 } else if !out.ends_with('-') {
46 out.push('-');
47 }
48 }
49 out.trim_matches('-').to_owned()
50}
51
52fn cell(text: &str) -> String {
53 text.replace('|', "\\|").replace('\n', " ")
54}
55
56fn flag(arg: &Arg) -> String {
57 let value = arg
58 .get_value_names()
59 .map(|v| {
60 v.iter()
61 .map(|n| n.to_string())
62 .collect::<Vec<_>>()
63 .join(" ")
64 })
65 .unwrap_or_else(|| arg.get_id().to_string().to_uppercase());
66 let takes_value = matches!(arg.get_action(), ArgAction::Set | ArgAction::Append);
67 let optional = arg.get_num_args().is_some_and(|n| n.min_values() == 0);
68 let mut name = match (arg.get_short(), arg.get_long()) {
69 (Some(s), Some(l)) => format!("-{s}, --{l}"),
70 (None, Some(l)) => format!("--{l}"),
71 (Some(s), None) => format!("-{s}"),
72 (None, None) => {
73 return if arg.is_required_set() {
74 format!("<{value}>")
75 } else {
76 format!("[{value}]")
77 };
78 }
79 };
80 if takes_value {
81 name.push_str(&if optional {
82 format!(" [{value}]")
83 } else {
84 format!(" <{value}>")
85 });
86 }
87 name
88}
89
90fn describe(arg: &Arg) -> String {
91 let mut text = sentence(&arg.get_help().map(|h| h.to_string()).unwrap_or_default());
92 let values: Vec<String> = arg
93 .get_possible_values()
94 .iter()
95 .filter(|v| !v.is_hide_set())
96 .map(|v| format!("`{}`", v.get_name()))
97 .collect();
98 if !values.is_empty() && !text.contains(&values.join(", ")) {
99 let _ = write!(
100 text,
101 "{}One of {}.",
102 if text.is_empty() { "" } else { " " },
103 values.join(", ")
104 );
105 }
106 let defaults: Vec<String> = arg
107 .get_default_values()
108 .iter()
109 .map(|v| v.to_string_lossy().into_owned())
110 .collect();
111 if !defaults.is_empty() && !matches!(arg.get_action(), ArgAction::SetTrue) {
112 let _ = write!(text, " Default: `{}`.", defaults.join(","));
113 }
114 if arg.is_last_set() {
115 text = format!("After `--`. {text}");
116 }
117 if let Some(env) = arg.get_env() {
118 let _ = write!(text, " Also `{}`.", env.to_string_lossy());
119 }
120 cell(text.trim())
121}
122
123/// The flags a command group takes for every command under it (`g1t work
124/// --workspace`): its global ones that no command above it has.
125fn group_flags<'a>(cmd: &'a Command, above: &[String]) -> Vec<&'a Arg> {
126 cmd.get_arguments()
127 .filter(|a| {
128 a.is_global_set()
129 && !a.is_hide_set()
130 && !above.iter().any(|id| id == a.get_id().as_str())
131 })
132 .collect()
133}
134
135fn command(out: &mut String, cmd: &Command, path: &str, depth: usize, above: &[String]) {
136 if cmd.is_hide_set() || cmd.get_name() == "help" {
137 return;
138 }
139 let subs: Vec<&Command> = cmd
140 .get_subcommands()
141 .filter(|c| !c.is_hide_set() && c.get_name() != "help")
142 .collect();
143 let heading = "#".repeat(depth.min(4));
144 let _ = writeln!(out, "{heading} {path}\n");
145 if let Some(about) = cmd.get_about() {
146 let _ = writeln!(out, "{}\n", sentence(&about.to_string()));
147 }
148 if !subs.is_empty() {
149 let own = group_flags(cmd, above);
150 if !own.is_empty() {
151 let _ = writeln!(
152 out,
153 "Flags every `{path}` command takes:\n\n| Flag | |\n| --- | --- |"
154 );
155 for arg in &own {
156 let _ = writeln!(out, "| `{}` | {} |", cell(&flag(arg)), describe(arg));
157 }
158 let _ = writeln!(out);
159 }
160 let mut above = above.to_vec();
161 above.extend(own.iter().map(|a| a.get_id().to_string()));
162 for sub in &subs {
163 command(
164 out,
165 sub,
166 &format!("{path} {}", sub.get_name()),
167 depth + 1,
168 &above,
169 );
170 }
171 return;
172 }
173 let usage = cmd.clone().render_usage().to_string();
174 let usage = usage.trim_start_matches("Usage: ").trim();
175 let _ = writeln!(out, "```text\n{usage}\n```\n");
176 let args: Vec<&Arg> = cmd
177 .get_arguments()
178 .filter(|a| {
179 !a.is_hide_set()
180 && !a.is_global_set()
181 && !matches!(a.get_id().as_str(), "help" | "version")
182 })
183 .collect();
184 if !args.is_empty() {
185 let _ = writeln!(out, "| Argument or flag | |\n| --- | --- |");
186 for arg in args {
187 let _ = writeln!(out, "| `{}` | {} |", cell(&flag(arg)), describe(arg));
188 }
189 let _ = writeln!(out);
190 }
191 if let Some(after) = cmd.get_after_help() {
192 let _ = writeln!(out, "```text\n{}\n```\n", after.to_string().trim());
193 }
194 let key = path.trim_start_matches("g1t ");
195 if let Some(m) = crate::mapping::COMMANDS
196 .iter()
197 .find(|m| m.command == key)
198 .filter(|m| !m.ops.is_empty())
199 {
200 let links: Vec<String> = m
201 .ops
202 .iter()
203 .map(|id| {
204 let tag = crate::ops::find(id).map(|op| op.tag).unwrap_or("");
205 format!("[`{id}`](/reference/api/{}/{}/)", slug(tag), slug(id))
206 })
207 .collect();
208 let _ = writeln!(out, "Calls {}.\n", links.join(", "));
209 }
210}
211
212pub fn reference() -> String {
213 let mut root = <crate::cli::Cli as clap::CommandFactory>::command();
214 root.build();
215 let mut out = HEAD.to_owned();
216 let globals = group_flags(&root, &[]);
217 for arg in &globals {
218 let _ = writeln!(out, "| `{}` | {} |", cell(&flag(arg)), describe(arg));
219 }
220 let _ = writeln!(out);
221 let above: Vec<String> = globals.iter().map(|a| a.get_id().to_string()).collect();
222 for sub in root.get_subcommands() {
223 command(&mut out, sub, &format!("g1t {}", sub.get_name()), 2, &above);
224 }
225 out.trim_end().to_owned() + "\n"
226}
227
228/// The `g1t …` command lines in a page: in code blocks (a line starting
229/// with `g1t `, or `$ g1t `) and in inline code. Each is cut at the first
230/// shell operator or comment.
231pub fn command_lines(page: &str) -> Vec<String> {
232 let mut found = Vec::new();
233 let mut in_block = false;
234 let mut take = |text: &str| {
235 let text = text.trim();
236 let text = text.strip_prefix("$ ").unwrap_or(text);
237 if !text.starts_with("g1t ") {
238 return;
239 }
240 let end = [
241 " | ", " && ", " || ", " ; ", "; ", " # ", " > ", " < ", "`", " 2>",
242 ]
243 .iter()
244 .filter_map(|op| text.find(op))
245 .min()
246 .unwrap_or(text.len());
247 found.push(text[..end].trim().to_owned());
248 };
249 for line in page.lines() {
250 if line.trim_start().starts_with("```") {
251 in_block = !in_block;
252 continue;
253 }
254 if in_block {
255 take(line);
256 continue;
257 }
258 for (i, part) in line.split('`').enumerate() {
259 if i % 2 == 1 {
260 take(part);
261 }
262 }
263 }
264 found
265}
266
267/// What is wrong with a documented command line: a command the CLI does
268/// not have, or a flag the command does not take. None when it is right, or
269/// when it cannot be told (an alias, a placeholder).
270pub fn wrong(line: &str) -> Option<String> {
271 let mut root = <crate::cli::Cli as clap::CommandFactory>::command();
272 root.build();
273 let words = shell_words(line);
274 // Not a command: "g1t / deploy" (a check's name), "g1t <noreply@…>".
275 let command_word = |w: &String| {
276 w.chars()
277 .next()
278 .is_some_and(|c| c.is_ascii_lowercase() || c == '-')
279 };
280 if !words.get(1).is_some_and(command_word) {
281 return None;
282 }
283 let mut cmd = &root;
284 let mut path = vec!["g1t".to_owned()];
285 let mut rest = words.iter().skip(1).peekable();
286 while let Some(word) = rest.peek() {
287 if word.starts_with('-') || !cmd.has_subcommands() {
288 break;
289 }
290 match cmd.find_subcommand(word.as_str()) {
291 Some(sub) => {
292 path.push(word.to_string());
293 cmd = sub;
294 rest.next();
295 }
296 // The aliases the docs make, and placeholders like COMMAND, are not checked.
297 None if path.len() == 1 && DOC_ALIASES.contains(&word.as_str()) => return None,
298 None => {
299 if word
300 .chars()
301 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '<' || c == '>')
302 {
303 return None;
304 }
305 return Some(format!("`{}` has no command `{word}`", path.join(" ")));
306 }
307 }
308 }
309 let globals: Vec<&clap::Arg> = root.get_arguments().collect();
310 let rest: Vec<&String> = rest.collect();
311 for (i, word) in rest.iter().enumerate() {
312 if *word == "--" {
313 break;
314 }
315 let Some(flag) = word.strip_prefix("--") else {
316 continue;
317 };
318 let (flag, inline) = match flag.split_once('=') {
319 Some((flag, value)) => (flag, Some(value)),
320 None => (flag, None),
321 };
322 if flag.is_empty()
323 || flag.starts_with(['<', '['])
324 || flag.chars().any(|c| c.is_ascii_uppercase())
325 {
326 continue;
327 }
328 let known = |arg: &&clap::Arg| {
329 arg.get_long() == Some(flag) || arg.get_all_aliases().is_some_and(|a| a.contains(&flag))
330 };
331 let Some(arg) = cmd
332 .get_arguments()
333 .find(known)
334 .or_else(|| globals.iter().copied().find(known))
335 else {
336 if matches!(flag, "help" | "version") {
337 continue;
338 }
339 return Some(format!("`{}` takes no --{flag}", path.join(" ")));
340 };
341 // A value from a fixed list: one of them (a placeholder is not checked).
342 let values = arg.get_possible_values();
343 let value = inline.or_else(|| rest.get(i + 1).map(|v| v.as_str()));
344 let literal = |v: &&str| {
345 v.chars()
346 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
347 };
348 let unknown = |v: &&str| !values.iter().any(|p| p.matches(v, false));
349 if let Some(value) = value.filter(|v| !values.is_empty() && literal(v) && unknown(v)) {
350 let names: Vec<String> = values.iter().map(|v| v.get_name().to_owned()).collect();
351 return Some(format!(
352 "`{} --{flag}` takes {}, not {value}",
353 path.join(" "),
354 names.join(", ")
355 ));
356 }
357 }
358 None
359}
360
361/// Aliases the docs show being made (`g1t alias set …`) and then used.
362const DOC_ALIASES: &[&str] = &["bugs", "co", "mine"];
363
364/// Splits a command line at spaces outside quotes.
365fn shell_words(line: &str) -> Vec<String> {
366 let mut words = Vec::new();
367 let mut word = String::new();
368 let mut quote = None;
369 for c in line.chars() {
370 match (quote, c) {
371 (None, '"' | '\'') => quote = Some(c),
372 (Some(q), c) if c == q => quote = None,
373 (None, ' ' | '\t') => {
374 if !word.is_empty() {
375 words.push(std::mem::take(&mut word));
376 }
377 }
378 (_, c) => word.push(c),
379 }
380 }
381 if !word.is_empty() {
382 words.push(word);
383 }
384 words
385}
386
387#[cfg(test)]
388mod tests {
389 /// Every `g1t …` command in the docs is one the CLI has, with flags it takes.
390 #[test]
391 fn docs_name_real_commands_and_flags() {
392 // The docs, and the plugins' skills, commands and instructions.
393 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../..");
394 let mut pages: Vec<std::path::PathBuf> =
395 ["apps/docs/src/content/docs", "plugins", "README.md"]
396 .iter()
397 .map(|p| std::path::Path::new(root).join(p))
398 .collect();
399 let mut problems = Vec::new();
400 let mut checked = 0;
401 while let Some(path) = pages.pop() {
402 if path.is_dir() {
403 pages.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
404 continue;
405 }
406 let name = path.to_string_lossy().replace('\\', "/");
407 // The reference is written from the CLI itself, and the note on
408 // issues becoming work items names the commands that went.
409 if !(name.ends_with(".md") || name.ends_with(".mdx"))
410 || name.ends_with("reference/cli.md")
411 || name.ends_with("about/issues-became-work-items.md")
412 {
413 continue;
414 }
415 let page = std::fs::read_to_string(&path).unwrap();
416 for line in super::command_lines(&page) {
417 checked += 1;
418 if let Some(problem) = super::wrong(&line) {
419 let short = name
420 .split("content/docs/")
421 .last()
422 .unwrap_or(&name)
423 .to_owned();
424 problems.push(format!("{short}: {line}: {problem}"));
425 }
426 }
427 }
428 assert!(checked > 100, "found only {checked} command lines");
429 assert!(
430 problems.is_empty(),
431 "commands the CLI does not have:\n{}",
432 problems.join("\n")
433 );
434 }
435
436 #[test]
437 fn a_wrong_command_or_flag_is_found() {
438 assert_eq!(super::wrong("g1t session start --harness codex"), None);
439 assert_eq!(super::wrong("g1t pr create --item N --draft --fill"), None);
440 assert!(super::wrong("g1t issue list").is_some());
441 assert!(super::wrong("g1t session pause").is_some());
442 assert!(super::wrong("g1t session start --hearbeat").is_some());
443 assert!(super::wrong("g1t session start --harness claude-managed-agents").is_some());
444 assert_eq!(super::wrong("g1t session log --kind tool-call x"), None);
445 assert_eq!(
446 super::wrong("g1t session start --harness=claude_code"),
447 None
448 );
449 assert_eq!(super::wrong("g1t / deploy"), None, "not a command");
450 assert_eq!(super::wrong("g1t co 12"), None, "an alias is not checked");
451 assert_eq!(
452 super::command_lines(
453 "Run `g1t work claim 12 --note x` and\n```sh\n$ g1t pr view | head\n```"
454 ),
455 vec!["g1t work claim 12 --note x", "g1t pr view"]
456 );
457 }
458
459 #[test]
460 fn cli_reference_is_current() {
461 let path = concat!(
462 env!("CARGO_MANIFEST_DIR"),
463 "/../../apps/docs/src/content/docs/reference/cli.md"
464 );
465 let want = super::reference();
466 if std::env::var_os("G1T_WRITE_DOCS").is_some() {
467 std::fs::write(path, &want).unwrap();
468 return;
469 }
470 let have = std::fs::read_to_string(path)
471 .unwrap_or_default()
472 .replace("\r\n", "\n");
473 assert!(
474 have == want,
475 "apps/docs/src/content/docs/reference/cli.md is out of date: run G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference"
476 );
477 }
478}