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
442 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 cell(text.trim())
118}
119
120fn command(out: &mut String, cmd: &Command, path: &str, depth: usize) {
121 if cmd.is_hide_set() || cmd.get_name() == "help" {
122 return;
123 }
124 let subs: Vec<&Command> = cmd
125 .get_subcommands()
126 .filter(|c| !c.is_hide_set() && c.get_name() != "help")
127 .collect();
128 let heading = "#".repeat(depth.min(4));
129 let _ = writeln!(out, "{heading} {path}\n");
130 if let Some(about) = cmd.get_about() {
131 let _ = writeln!(out, "{}\n", sentence(&about.to_string()));
132 }
133 if !subs.is_empty() {
134 for sub in &subs {
135 command(out, sub, &format!("{path} {}", sub.get_name()), depth + 1);
136 }
137 return;
138 }
139 let usage = cmd.clone().render_usage().to_string();
140 let usage = usage.trim_start_matches("Usage: ").trim();
141 let _ = writeln!(out, "```text\n{usage}\n```\n");
142 let args: Vec<&Arg> = cmd
143 .get_arguments()
144 .filter(|a| {
145 !a.is_hide_set()
146 && !a.is_global_set()
147 && !matches!(a.get_id().as_str(), "help" | "version")
148 })
149 .collect();
150 if !args.is_empty() {
151 let _ = writeln!(out, "| Argument or flag | |\n| --- | --- |");
152 for arg in args {
153 let _ = writeln!(out, "| `{}` | {} |", cell(&flag(arg)), describe(arg));
154 }
155 let _ = writeln!(out);
156 }
157 if let Some(after) = cmd.get_after_help() {
158 let _ = writeln!(out, "```text\n{}\n```\n", after.to_string().trim());
159 }
160 let key = path.trim_start_matches("g1t ");
161 if let Some(m) = crate::mapping::COMMANDS
162 .iter()
163 .find(|m| m.command == key)
164 .filter(|m| !m.ops.is_empty())
165 {
166 let links: Vec<String> = m
167 .ops
168 .iter()
169 .map(|id| {
170 let tag = crate::ops::find(id).map(|op| op.tag).unwrap_or("");
171 format!("[`{id}`](/reference/api/{}/{}/)", slug(tag), slug(id))
172 })
173 .collect();
174 let _ = writeln!(out, "Calls {}.\n", links.join(", "));
175 }
176}
177
178pub fn reference() -> String {
179 let mut root = <crate::cli::Cli as clap::CommandFactory>::command();
180 root.build();
181 let mut out = HEAD.to_owned();
182 for arg in root
183 .get_arguments()
184 .filter(|a| a.is_global_set() && !a.is_hide_set())
185 {
186 let _ = writeln!(out, "| `{}` | {} |", cell(&flag(arg)), describe(arg));
187 }
188 let _ = writeln!(out);
189 for sub in root.get_subcommands() {
190 command(&mut out, sub, &format!("g1t {}", sub.get_name()), 2);
191 }
192 out.trim_end().to_owned() + "\n"
193}
194
195/// The `g1t …` command lines in a page: in code blocks (a line starting
196/// with `g1t `, or `$ g1t `) and in inline code. Each is cut at the first
197/// shell operator or comment.
198pub fn command_lines(page: &str) -> Vec<String> {
199 let mut found = Vec::new();
200 let mut in_block = false;
201 let mut take = |text: &str| {
202 let text = text.trim();
203 let text = text.strip_prefix("$ ").unwrap_or(text);
204 if !text.starts_with("g1t ") {
205 return;
206 }
207 let end = [
208 " | ", " && ", " || ", " ; ", "; ", " # ", " > ", " < ", "`", " 2>",
209 ]
210 .iter()
211 .filter_map(|op| text.find(op))
212 .min()
213 .unwrap_or(text.len());
214 found.push(text[..end].trim().to_owned());
215 };
216 for line in page.lines() {
217 if line.trim_start().starts_with("```") {
218 in_block = !in_block;
219 continue;
220 }
221 if in_block {
222 take(line);
223 continue;
224 }
225 for (i, part) in line.split('`').enumerate() {
226 if i % 2 == 1 {
227 take(part);
228 }
229 }
230 }
231 found
232}
233
234/// What is wrong with a documented command line: a command the CLI does
235/// not have, or a flag the command does not take. None when it is right, or
236/// when it cannot be told (an alias, a placeholder).
237pub fn wrong(line: &str) -> Option<String> {
238 let mut root = <crate::cli::Cli as clap::CommandFactory>::command();
239 root.build();
240 let words = shell_words(line);
241 // Not a command: "g1t / deploy" (a check's name), "g1t <noreply@…>".
242 let command_word = |w: &String| {
243 w.chars()
244 .next()
245 .is_some_and(|c| c.is_ascii_lowercase() || c == '-')
246 };
247 if !words.get(1).is_some_and(command_word) {
248 return None;
249 }
250 let mut cmd = &root;
251 let mut path = vec!["g1t".to_owned()];
252 let mut rest = words.iter().skip(1).peekable();
253 while let Some(word) = rest.peek() {
254 if word.starts_with('-') || !cmd.has_subcommands() {
255 break;
256 }
257 match cmd.find_subcommand(word.as_str()) {
258 Some(sub) => {
259 path.push(word.to_string());
260 cmd = sub;
261 rest.next();
262 }
263 // The aliases the docs make, and placeholders like COMMAND, are not checked.
264 None if path.len() == 1 && DOC_ALIASES.contains(&word.as_str()) => return None,
265 None => {
266 if word
267 .chars()
268 .all(|c| c.is_ascii_uppercase() || c == '_' || c == '<' || c == '>')
269 {
270 return None;
271 }
272 return Some(format!("`{}` has no command `{word}`", path.join(" ")));
273 }
274 }
275 }
276 let globals: Vec<&clap::Arg> = root.get_arguments().collect();
277 let rest: Vec<&String> = rest.collect();
278 for (i, word) in rest.iter().enumerate() {
279 if *word == "--" {
280 break;
281 }
282 let Some(flag) = word.strip_prefix("--") else {
283 continue;
284 };
285 let (flag, inline) = match flag.split_once('=') {
286 Some((flag, value)) => (flag, Some(value)),
287 None => (flag, None),
288 };
289 if flag.is_empty()
290 || flag.starts_with(['<', '['])
291 || flag.chars().any(|c| c.is_ascii_uppercase())
292 {
293 continue;
294 }
295 let known = |arg: &&clap::Arg| {
296 arg.get_long() == Some(flag) || arg.get_all_aliases().is_some_and(|a| a.contains(&flag))
297 };
298 let Some(arg) = cmd
299 .get_arguments()
300 .find(known)
301 .or_else(|| globals.iter().copied().find(known))
302 else {
303 if matches!(flag, "help" | "version") {
304 continue;
305 }
306 return Some(format!("`{}` takes no --{flag}", path.join(" ")));
307 };
308 // A value from a fixed list: one of them (a placeholder is not checked).
309 let values = arg.get_possible_values();
310 let value = inline.or_else(|| rest.get(i + 1).map(|v| v.as_str()));
311 let literal = |v: &&str| {
312 v.chars()
313 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
314 };
315 let unknown = |v: &&str| !values.iter().any(|p| p.matches(v, false));
316 if let Some(value) = value.filter(|v| !values.is_empty() && literal(v) && unknown(v)) {
317 let names: Vec<String> = values.iter().map(|v| v.get_name().to_owned()).collect();
318 return Some(format!(
319 "`{} --{flag}` takes {}, not {value}",
320 path.join(" "),
321 names.join(", ")
322 ));
323 }
324 }
325 None
326}
327
328/// Aliases the docs show being made (`g1t alias set …`) and then used.
329const DOC_ALIASES: &[&str] = &["bugs", "co", "mine"];
330
331/// Splits a command line at spaces outside quotes.
332fn shell_words(line: &str) -> Vec<String> {
333 let mut words = Vec::new();
334 let mut word = String::new();
335 let mut quote = None;
336 for c in line.chars() {
337 match (quote, c) {
338 (None, '"' | '\'') => quote = Some(c),
339 (Some(q), c) if c == q => quote = None,
340 (None, ' ' | '\t') => {
341 if !word.is_empty() {
342 words.push(std::mem::take(&mut word));
343 }
344 }
345 (_, c) => word.push(c),
346 }
347 }
348 if !word.is_empty() {
349 words.push(word);
350 }
351 words
352}
353
354#[cfg(test)]
355mod tests {
356 /// Every `g1t …` command in the docs is one the CLI has, with flags it takes.
357 #[test]
358 fn docs_name_real_commands_and_flags() {
359 // The docs, and the plugins' skills, commands and instructions.
360 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../..");
361 let mut pages: Vec<std::path::PathBuf> =
362 ["apps/docs/src/content/docs", "plugins", "README.md"]
363 .iter()
364 .map(|p| std::path::Path::new(root).join(p))
365 .collect();
366 let mut problems = Vec::new();
367 let mut checked = 0;
368 while let Some(path) = pages.pop() {
369 if path.is_dir() {
370 pages.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
371 continue;
372 }
373 let name = path.to_string_lossy().replace('\\', "/");
374 // The reference is written from the CLI itself.
375 if !(name.ends_with(".md") || name.ends_with(".mdx"))
376 || name.ends_with("reference/cli.md")
377 {
378 continue;
379 }
380 let page = std::fs::read_to_string(&path).unwrap();
381 for line in super::command_lines(&page) {
382 checked += 1;
383 if let Some(problem) = super::wrong(&line) {
384 let short = name
385 .split("content/docs/")
386 .last()
387 .unwrap_or(&name)
388 .to_owned();
389 problems.push(format!("{short}: {line}: {problem}"));
390 }
391 }
392 }
393 assert!(checked > 100, "found only {checked} command lines");
394 assert!(
395 problems.is_empty(),
396 "commands the CLI does not have:\n{}",
397 problems.join("\n")
398 );
399 }
400
401 #[test]
402 fn a_wrong_command_or_flag_is_found() {
403 assert_eq!(super::wrong("g1t session start --harness codex"), None);
404 assert_eq!(super::wrong("g1t pr create --issue N --draft --fill"), None);
405 assert!(super::wrong("g1t session pause").is_some());
406 assert!(super::wrong("g1t session start --hearbeat").is_some());
407 assert!(super::wrong("g1t session start --harness claude-managed-agents").is_some());
408 assert_eq!(super::wrong("g1t session log --kind tool-call x"), None);
409 assert_eq!(
410 super::wrong("g1t session start --harness=claude_code"),
411 None
412 );
413 assert_eq!(super::wrong("g1t / deploy"), None, "not a command");
414 assert_eq!(super::wrong("g1t co 12"), None, "an alias is not checked");
415 assert_eq!(
416 super::command_lines(
417 "Run `g1t work claim 12 --note x` and\n```sh\n$ g1t pr view | head\n```"
418 ),
419 vec!["g1t work claim 12 --note x", "g1t pr view"]
420 );
421 }
422
423 #[test]
424 fn cli_reference_is_current() {
425 let path = concat!(
426 env!("CARGO_MANIFEST_DIR"),
427 "/../../apps/docs/src/content/docs/reference/cli.md"
428 );
429 let want = super::reference();
430 if std::env::var_os("G1T_WRITE_DOCS").is_some() {
431 std::fs::write(path, &want).unwrap();
432 return;
433 }
434 let have = std::fs::read_to_string(path)
435 .unwrap_or_default()
436 .replace("\r\n", "\n");
437 assert!(
438 have == want,
439 "apps/docs/src/content/docs/reference/cli.md is out of date: run G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference"
440 );
441 }
442}