Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ axum = "0.8"
base64 = "0.22"
blake3 = "1.8"
clap = { version = "4.6", features = ["derive"] }
clap_complete = "4.6"
clap_mangen = "0.2"
criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }
ed25519-dalek = "2.2"
flate2 = "1.1"
Expand Down
1 change: 1 addition & 0 deletions changes/WI-2540.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- graft-cli: add generated shell completions for bash, zsh, fish, elvish and PowerShell, plus a hidden command that writes man pages for every public command. Hidden and experimental commands are excluded from both outputs. (WI-2540)
2 changes: 2 additions & 0 deletions crates/graft-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ workspace = true
[dependencies]
base64.workspace = true
clap.workspace = true
clap_complete.workspace = true
clap_mangen.workspace = true
graft-client.workspace = true
graft-mirror = { path = "../graft-mirror" }
graft-node = { path = "../graft-node" }
Expand Down
37 changes: 36 additions & 1 deletion crates/graft-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
use std::path::PathBuf;

use clap::parser::ValueSource;
use clap::{ArgMatches, Args, Parser, Subcommand};
use clap::{ArgMatches, Args, CommandFactory, Parser, Subcommand};
use clap_complete::Shell;

/// The default line when no working copy or `--at` names a snapshot.
pub(crate) const DEFAULT_LINE: &str = "main";
Expand Down Expand Up @@ -134,6 +135,19 @@ pub(crate) enum Command {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Print a shell completion script
Completions {
/// Shell to generate completions for
#[arg(value_enum)]
shell: Shell,
},
/// Generate manual pages
#[command(hide = true)]
Man {
/// Directory to write manual pages into
#[arg(long, value_name = "DIR")]
out: PathBuf,
},
/// Run the authoritative server
#[command(hide = true)]
Serve(ServeArgs),
Expand All @@ -148,6 +162,27 @@ pub(crate) enum Command {
Planned(Vec<String>),
}

/// The public command tree used by generated documentation and completions.
pub(crate) fn public_command() -> clap::Command {
let command = Cli::command();
clap::Command::new("graft")
.about("The graft command-line client.")
.disable_version_flag(true)
.arg_required_else_help(true)
.args(
command
.get_arguments()
.filter(|argument| !argument.is_hide_set())
.cloned(),
)
.subcommands(
command
.get_subcommands()
.filter(|subcommand| !subcommand.is_hide_set())
.cloned(),
)
}

/// Which MCP client a registration snippet is for.
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub(crate) enum McpClient {
Expand Down
46 changes: 46 additions & 0 deletions crates/graft-cli/src/cmd/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Shell completion and manual-page generation from the clap command tree.

use std::fs::{self, File};
use std::io;
use std::path::Path;

use clap_complete::Shell;
use clap_mangen::Man;

use crate::Exit;
use crate::cli;
use crate::error::Failure;

/// Prints a completion script for `shell`.
pub(crate) fn run(shell: Shell) -> Result<(), Failure> {
let mut command = cli::public_command();
clap_complete::generate(shell, &mut command, "graft", &mut io::stdout());
Ok(())
}

/// Writes one manual page for graft and each visible top-level subcommand.
pub(crate) fn man(out: &Path) -> Result<(), Failure> {
fs::create_dir_all(out).map_err(|error| io_failure(out, error))?;

let command = cli::public_command();
render(&command, &out.join("graft.1"))?;
for subcommand in command
.get_subcommands()
.filter(|subcommand| !subcommand.is_hide_set())
{
let name = subcommand.get_name();
render(subcommand, &out.join(format!("graft-{name}.1")))?;
}
Ok(())
}

fn render(command: &clap::Command, path: &Path) -> Result<(), Failure> {
let mut file = File::create(path).map_err(|error| io_failure(path, error))?;
Man::new(command.clone())
.render(&mut file)
.map_err(|error| io_failure(path, error))
}

fn io_failure(path: &Path, error: io::Error) -> Failure {
Failure::new(Exit::Failure, "io", format!("{}: {error}", path.display()))
}
1 change: 1 addition & 0 deletions crates/graft-cli/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub(crate) mod cat;
pub(crate) mod ci;
pub(crate) mod clone;
pub(crate) mod commit;
pub(crate) mod completions;
pub(crate) mod config;
pub(crate) mod diff;
pub(crate) mod init;
Expand Down
2 changes: 2 additions & 0 deletions crates/graft-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ fn dispatch(cli: &Cli) -> Result<(), Failure> {
Exit::Ok => Ok(()),
code => Err(Failure::silent(code)),
},
Command::Completions { shell } => cmd::completions::run(*shell),
Command::Man { out } => cmd::completions::man(out),
Command::Serve(_) | Command::Node(_) | Command::Mirror(_) => {
unreachable!("role command")
}
Expand Down
49 changes: 10 additions & 39 deletions crates/graft-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ fn top_level_help_snapshot() {
insta::assert_snapshot!("top_level_help", text(out.get_output()));
}

#[test]
fn completions_help_snapshot() {
let dir = scratch();
let out = graft(dir.path())
.args(["completions", "--help"])
.assert()
.success();
insta::assert_snapshot!("completions_help", text(out.get_output()));
}

#[test]
fn experimental_commands_are_hidden_and_gated() {
let dir = scratch();
Expand All @@ -174,45 +184,6 @@ fn experimental_commands_are_hidden_and_gated() {
.stderr(contains("experimental; enable with GRAFT_EXPERIMENTAL=1"));
}

fn assert_no_internal_references(command: &[&str], help: &str) {
const LITERALS: &[&str] = &[
"pr bridge",
"crates/",
"owned by",
"vogt",
"trial",
"graft-dev",
"phase",
];
let lowercase = help.to_ascii_lowercase();
for leak in LITERALS {
assert!(
!lowercase.contains(leak),
"graft {} --help leaked {leak:?}:\n{help}",
command.join(" ")
);
}
for word in help.split(|character: char| !character.is_ascii_alphanumeric() && character != '-')
{
let numbered = (word.starts_with("WI-")
&& word.len() > 3
&& word[3..]
.chars()
.all(|character| character.is_ascii_digit()))
|| ((word.starts_with('D') || word.starts_with('G'))
&& word.len() > 1
&& word[1..]
.chars()
.next()
.is_some_and(|character| character.is_ascii_digit()));
assert!(
!numbered,
"graft {} --help leaked internal reference {word:?}:\n{help}",
command.join(" ")
);
}
}

#[test]
fn clone_human_and_json() {
let s = serve();
Expand Down
104 changes: 104 additions & 0 deletions crates/graft-cli/tests/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Generated shell completion and manual-page acceptance tests.

mod support;

use std::fs;

use support::{assert_no_internal_references, graft, scratch};

const HIDDEN_COMMANDS: &[&str] = &["attest", "ci", "serve", "node", "mirror", "man"];
const HIDDEN_DESCRIPTIONS: &[&str] = &[
"Record a check result for a changeset",
"CI helpers",
"Run the authoritative server",
"Run the host cache daemon",
"Import, follow, batch or export git repositories",
"Generate manual pages",
"Commands designed but not built yet",
];

#[test]
fn completions_cover_public_commands_and_options() {
for shell in ["bash", "zsh", "fish", "elvish", "powershell"] {
let dir = scratch();
let output = graft(dir.path())
.args(["completions", shell])
.output()
.expect("completion command runs");
assert!(output.status.success(), "{shell}: {output:?}");
let script = String::from_utf8(output.stdout).expect("completion script is text");
assert!(!script.is_empty(), "{shell} completion is empty");
for expected in ["land", "sync"] {
assert!(
script.contains(expected),
"{shell} completion lacks {expected}"
);
}
let json_option = if shell == "fish" { "-l json" } else { "--json" };
assert!(
script.contains(json_option),
"{shell} completion lacks --json"
);
for hidden in HIDDEN_DESCRIPTIONS {
assert!(
!script.contains(hidden),
"{shell} completion exposed hidden command {hidden}"
);
}
assert_no_internal_references(&["completions", shell], &script);
}
}

#[test]
fn man_writes_root_and_every_visible_subcommand() {
let dir = scratch();
let out = dir.path().join("man");
graft(dir.path())
.args(["man", "--out"])
.arg(&out)
.assert()
.success();

let root = fs::read_to_string(out.join("graft.1")).expect("root man page");
let land = fs::read_to_string(out.join("graft-land.1")).expect("land man page");
assert!(!root.is_empty());
assert!(!land.is_empty());
assert_no_internal_references(&["man"], &root);
assert_no_internal_references(&["man", "land"], &land);

for hidden in HIDDEN_COMMANDS {
assert!(
!out.join(format!("graft-{hidden}.1")).exists(),
"wrote a page for hidden command {hidden}"
);
assert!(
!root.contains(&format!("graft\\-{hidden}(1)")),
"root page exposed hidden command {hidden}"
);
}

for visible in public_subcommands() {
let page = out.join(format!("graft-{visible}.1"));
let contents =
fs::read_to_string(&page).unwrap_or_else(|error| panic!("{}: {error}", page.display()));
assert!(!contents.is_empty(), "{} is empty", page.display());
assert_no_internal_references(&["man", &visible], &contents);
}
}

fn public_subcommands() -> Vec<String> {
let dir = scratch();
let output = graft(dir.path())
.arg("--help")
.output()
.expect("help command runs");
let help = String::from_utf8(output.stdout).expect("help is text");
help.lines()
.skip_while(|line| *line != "Commands:")
.skip(1)
.take_while(|line| line.starts_with(" "))
.filter_map(|line| line.split_whitespace().next())
.filter(|name| *name != "help")
.map(str::to_owned)
.collect()
}
19 changes: 19 additions & 0 deletions crates/graft-cli/tests/snapshots/cli__completions_help.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
source: crates/graft-cli/tests/cli.rs
expression: text(out.get_output())
---
Print a shell completion script

Usage: graft completions [OPTIONS] <SHELL>

Arguments:
<SHELL> Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh]

Options:
--json Print stable JSON; its schema is published with the release
--endpoint <URL> Where to send requests: `http://host:port`, `https://…`, or `unix:/path`
--repo <NAME> Repository name (default: the working copy's)
--token-file <PATH> File holding the bearer token. `GRAFT_TOKEN` is the other source. A token is never taken from an argument
--server <NAME> Named server in the user config. Its endpoint and token file are used; the token is never an argument
-C, --directory <DIR> Run as if started in this directory, like `git -C`
-h, --help Print help
Loading
Loading