Skip to content

Commit 2993807

Browse files
kylehgcclaude
andcommitted
fix(cli): keep rtk's help on meta commands, forward it past external-arm parents, surface git's usage
Review round 1 on #165: proxy/run/rewrite carry a trailing command but are rtk's own, so the rule now exempts RTK_META_COMMANDS. Parents whose passthrough is an external_subcommand arm (git, cargo, docker, ...) cannot be seen before clap builds, so the fourteen of them opt out per-variant, and the contract test builds the command to hold both groups to one invariant. git answers -h with its usage on stdout and exit 129; run_log/run_status now print that stdout instead of dropping it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent beb45c2 commit 2993807

3 files changed

Lines changed: 145 additions & 24 deletions

File tree

‎src/cmds/git/git.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,9 @@ fn run_log(
991991
let result = exec_capture(&mut cmd).context("Failed to run git log")?;
992992

993993
if !result.success() {
994+
// git answers `-h`/`--help` with its usage on stdout and exit 129;
995+
// dropping stdout here left that answer blank.
996+
print!("{}", result.stdout);
994997
eprintln!("{}", result.stderr);
995998
return Ok(result.exit_code);
996999
}
@@ -1530,6 +1533,8 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<i3
15301533
let result = exec_capture(&mut cmd).context("Failed to run git status")?;
15311534

15321535
if !result.success() {
1536+
// git answers `-h`/`--help` with its usage on stdout and exit 129.
1537+
print!("{}", result.stdout);
15331538
if !result.stderr.trim().is_empty() {
15341539
eprint!("{}", result.stderr);
15351540
}

‎src/main.rs‎

Lines changed: 118 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ enum Commands {
135135
},
136136

137137
/// Git commands with compact output
138+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
139+
#[command(disable_help_flag = true)]
138140
Git {
139141
/// Change to directory before executing (like git -C <path>, can be repeated)
140142
#[arg(short = 'C', action = clap::ArgAction::Append)]
@@ -214,6 +216,8 @@ enum Commands {
214216
},
215217

216218
/// pnpm commands with ultra-compact output
219+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
220+
#[command(disable_help_flag = true)]
217221
Pnpm {
218222
/// pnpm filter arguments (can be repeated: --filter @app1 --filter @app2)
219223
#[arg(long, short = 'F')]
@@ -285,24 +289,32 @@ enum Commands {
285289
},
286290

287291
/// .NET commands with compact output (build/test/restore/format)
292+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
293+
#[command(disable_help_flag = true)]
288294
Dotnet {
289295
#[command(subcommand)]
290296
command: DotnetCommands,
291297
},
292298

293299
/// Docker commands with compact output
300+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
301+
#[command(disable_help_flag = true)]
294302
Docker {
295303
#[command(subcommand)]
296304
command: DockerCommands,
297305
},
298306

299307
/// Kubectl commands with compact output
308+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
309+
#[command(disable_help_flag = true)]
300310
Kubectl {
301311
#[command(subcommand)]
302312
command: KubectlCommands,
303313
},
304314

305315
/// OpenShift CLI (oc) commands with compact output
316+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
317+
#[command(disable_help_flag = true)]
306318
Oc {
307319
#[command(subcommand)]
308320
command: OcCommands,
@@ -576,6 +588,8 @@ enum Commands {
576588
},
577589

578590
/// Cargo commands with compact output
591+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
592+
#[command(disable_help_flag = true)]
579593
Cargo {
580594
#[command(subcommand)]
581595
command: CargoCommands,
@@ -596,6 +610,8 @@ enum Commands {
596610
},
597611

598612
/// Bun runtime commands with compact output
613+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
614+
#[command(disable_help_flag = true)]
599615
Bun {
600616
#[command(subcommand)]
601617
command: BunCommands,
@@ -839,24 +855,32 @@ enum Commands {
839855
},
840856

841857
/// Deno runtime commands with compact output
858+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
859+
#[command(disable_help_flag = true)]
842860
Deno {
843861
#[command(subcommand)]
844862
command: DenoCommands,
845863
},
846864

847865
/// Go commands with compact output
866+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
867+
#[command(disable_help_flag = true)]
848868
Go {
849869
#[command(subcommand)]
850870
command: GoCommands,
851871
},
852872

853873
/// SBT (Scala Build Tool) commands with compact output
874+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
875+
#[command(disable_help_flag = true)]
854876
Sbt {
855877
#[command(subcommand)]
856878
command: SbtCommands,
857879
},
858880

859881
/// Graphite (gt) stacked PR commands with compact output
882+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
883+
#[command(disable_help_flag = true)]
860884
Gt {
861885
#[command(subcommand)]
862886
command: GtCommands,
@@ -1081,6 +1105,8 @@ enum DockerCommands {
10811105
/// Show container logs (deduplicated)
10821106
Logs { container: String },
10831107
/// Docker Compose commands with compact output
1108+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
1109+
#[command(disable_help_flag = true)]
10841110
Compose {
10851111
#[command(subcommand)]
10861112
command: ComposeCommands,
@@ -1384,6 +1410,8 @@ enum BunCommands {
13841410
args: Vec<String>,
13851411
},
13861412
/// Package manager commands (pm ls, etc.)
1413+
// `--help` belongs to the tool: see forward_help_to_wrapped_tools.
1414+
#[command(disable_help_flag = true)]
13871415
Pm {
13881416
#[command(subcommand)]
13891417
command: BunPmCommands,
@@ -1808,24 +1836,38 @@ fn is_native_test_expression(command: &[String]) -> bool {
18081836
}
18091837
}
18101838

1811-
/// Hand `--help`/`-h` to the wrapped tool on every subcommand that forwards
1812-
/// trailing hyphen arguments, recursively.
1839+
/// Hand `--help`/`-h` to the wrapped tool instead of clap, recursively.
1840+
///
1841+
/// A subcommand that carries a trailing-var-arg positional (`rtk tsc <args>`)
1842+
/// forwards every other flag to the tool; clap's auto help was the one it
1843+
/// kept, so `rtk tsc --help` printed rtk's one-line stub instead of running
1844+
/// tsc, and through the hook that is what `tsc --help` came back as.
1845+
///
1846+
/// The exact exemption is `RTK_META_COMMANDS`: rtk's own entry points keep
1847+
/// clap's help even when they take a trailing command (`proxy`, `run`,
1848+
/// `rewrite`), because there is no tool to hand it to. Subcommands with
1849+
/// nothing to forward (`gain`, `init`, …) are untouched by construction.
18131850
///
1814-
/// Those subcommands pass every other flag through to the tool, and clap's
1815-
/// auto help was the one it kept for itself: `rtk tsc --help` printed rtk's
1816-
/// one-line stub instead of running tsc, and through the hook that is what
1817-
/// `tsc --help` came back as. Subcommands with nothing to forward — rtk's own
1818-
/// `gain`, `init`, `rewrite`, … — keep clap's help. `Psql` and `Ctest` opted
1819-
/// out per-variant before this; the rule covers them and the ~100 siblings
1820-
/// that never did.
1851+
/// Parents whose passthrough is an `external_subcommand` arm (`rtk git <any>`)
1852+
/// cannot be told apart here: clap records that arm only while building, after
1853+
/// this pass. Those parents carry `#[command(disable_help_flag = true)]` on
1854+
/// the variant, and `test_every_wrapped_tool_subcommand_forwards_help` builds
1855+
/// the command to hold both groups to the same contract.
18211856
fn forward_help_to_wrapped_tools(cmd: clap::Command) -> clap::Command {
1822-
let forwards = cmd.get_positionals().any(|a| a.is_trailing_var_arg_set());
1857+
forward_help_below(cmd, 0)
1858+
}
1859+
1860+
/// `depth` 1 is a direct child of `rtk`: the meta-command names are only
1861+
/// rtk's own there (`rtk run` is, `rtk bun run` is bun's).
1862+
fn forward_help_below(cmd: clap::Command, depth: usize) -> clap::Command {
1863+
let meta = depth == 1 && core::constants::RTK_META_COMMANDS.contains(&cmd.get_name());
1864+
let forwards = cmd.get_positionals().any(|a| a.is_trailing_var_arg_set()) && !meta;
18231865
let cmd = if forwards {
18241866
cmd.disable_help_flag(true)
18251867
} else {
18261868
cmd
18271869
};
1828-
cmd.mut_subcommands(forward_help_to_wrapped_tools)
1870+
cmd.mut_subcommands(|sub| forward_help_below(sub, depth + 1))
18291871
}
18301872

18311873
/// The clap command `main` parses with: the derived `Cli` after
@@ -1834,8 +1876,10 @@ fn cli_command() -> clap::Command {
18341876
forward_help_to_wrapped_tools(<Cli as clap::CommandFactory>::command())
18351877
}
18361878

1837-
/// Parse argv the way `main` does. Tests reach for this rather than
1838-
/// `Cli::try_parse_from` when the help-flag routing matters.
1879+
/// Parse argv the way `main` does. `Cli::try_parse_from` is the derived
1880+
/// parser *before* [`forward_help_to_wrapped_tools`], so a test about
1881+
/// `--help`/`-h` on a wrapped tool must go through this or it tests a parser
1882+
/// `main` never runs.
18391883
fn parse_cli<I, T>(args: I) -> Result<Cli, clap::Error>
18401884
where
18411885
I: IntoIterator<Item = T>,
@@ -3166,10 +3210,49 @@ mod tests {
31663210
}
31673211
}
31683212

3213+
#[test]
3214+
fn test_parse_cli_forwards_help_past_an_external_subcommand_parent() {
3215+
// `rtk git --help`: the parent has no trailing positional, only an
3216+
// external-subcommand arm. clap must not answer with rtk's stub: the
3217+
// flag either lands in the external arm or fails the parse, and a
3218+
// parse error routes `run_fallback` to the raw `git --help`.
3219+
match parse_cli(["rtk", "git", "--help"]) {
3220+
Err(e) => assert_ne!(e.kind(), ErrorKind::DisplayHelp),
3221+
Ok(Cli {
3222+
command:
3223+
Commands::Git {
3224+
command: GitCommands::Other(args),
3225+
..
3226+
},
3227+
..
3228+
}) => assert_eq!(args, vec![OsString::from("--help")]),
3229+
Ok(_) => panic!("`--help` parsed as something other than git's external arm"),
3230+
}
3231+
3232+
let cli = parse_cli(["rtk", "git", "bisect", "--help"]).unwrap();
3233+
match cli.command {
3234+
Commands::Git {
3235+
command: GitCommands::Other(args),
3236+
..
3237+
} => assert_eq!(
3238+
args,
3239+
vec![OsString::from("bisect"), OsString::from("--help")]
3240+
),
3241+
_ => panic!("expected git's external subcommand arm"),
3242+
}
3243+
}
3244+
31693245
#[test]
31703246
fn test_parse_cli_keeps_help_on_meta_commands() {
3171-
// Nothing to forward to: rtk's own help stays.
3172-
for argv in [vec!["rtk", "--help"], vec!["rtk", "gain", "--help"]] {
3247+
// Nothing to forward to: rtk's own help stays, including on the meta
3248+
// commands that take a trailing command of their own.
3249+
for argv in [
3250+
vec!["rtk", "--help"],
3251+
vec!["rtk", "gain", "--help"],
3252+
vec!["rtk", "proxy", "--help"],
3253+
vec!["rtk", "run", "--help"],
3254+
vec!["rtk", "rewrite", "--help"],
3255+
] {
31733256
let err = match parse_cli(argv.clone()) {
31743257
Err(e) => e,
31753258
Ok(_) => panic!("{argv:?}: help must be clap's"),
@@ -3180,23 +3263,34 @@ mod tests {
31803263

31813264
#[test]
31823265
fn test_every_wrapped_tool_subcommand_forwards_help() {
3183-
// Total contract: a subcommand that forwards trailing hyphen args must
3184-
// not let clap claim `--help`; one that forwards nothing must keep it.
3185-
fn walk(cmd: &clap::Command, path: &str, seen: &mut usize) {
3186-
let forwards = cmd.get_positionals().any(|a| a.is_trailing_var_arg_set());
3187-
if forwards {
3266+
// Total contract: a subcommand that forwards to a tool (trailing
3267+
// hyphen args or an external subcommand) must not let clap claim
3268+
// `--help`; a meta command or one that forwards nothing must keep it.
3269+
fn walk(cmd: &clap::Command, path: &str, depth: usize, seen: &mut usize) {
3270+
let forwards = cmd.get_positionals().any(|a| a.is_trailing_var_arg_set())
3271+
|| cmd.is_allow_external_subcommands_set();
3272+
let meta = depth == 1 && core::constants::RTK_META_COMMANDS.contains(&cmd.get_name());
3273+
if forwards && !meta {
31883274
*seen += 1;
31893275
assert!(
31903276
cmd.is_disable_help_flag_set(),
31913277
"{path} forwards args but clap still owns --help"
31923278
);
3279+
} else if meta {
3280+
assert!(
3281+
!cmd.is_disable_help_flag_set(),
3282+
"{path} is rtk's own; clap must keep --help"
3283+
);
31933284
}
31943285
for sub in cmd.get_subcommands() {
3195-
walk(sub, &format!("{path} {}", sub.get_name()), seen);
3286+
walk(sub, &format!("{path} {}", sub.get_name()), depth + 1, seen);
31963287
}
31973288
}
31983289
let mut seen = 0;
3199-
walk(&cli_command(), "rtk", &mut seen);
3290+
// Built, so the external-subcommand arms are visible to the walk.
3291+
let mut cmd = cli_command();
3292+
cmd.build();
3293+
walk(&cmd, "rtk", 0, &mut seen);
32003294
assert!(seen >= 100, "expected the wrapped-tool surface, saw {seen}");
32013295
}
32023296

@@ -3735,7 +3829,7 @@ mod tests {
37353829
#[test]
37363830
fn test_ctest_help_and_version_passthrough_args() {
37373831
for flag in ["--help", "--version"] {
3738-
let cli = Cli::try_parse_from(["rtk", "ctest", flag]).unwrap();
3832+
let cli = parse_cli(["rtk", "ctest", flag]).unwrap();
37393833
match cli.command {
37403834
Commands::Ctest { args } => assert_eq!(args, vec![flag]),
37413835
_ => panic!("Expected Ctest command"),
@@ -3926,7 +4020,7 @@ mod tests {
39264020
fn test_rewrite_clap_double_dash_blocks_flag_injection() {
39274021
let injection_inputs = ["--help", "-h", "--version", "-V"];
39284022
for injected in injection_inputs {
3929-
let result = Cli::try_parse_from(["rtk", "rewrite", "--", injected]);
4023+
let result = parse_cli(["rtk", "rewrite", "--", injected]);
39304024
assert!(
39314025
result.is_ok(),
39324026
"`rtk rewrite -- {injected:?}` must parse without triggering clap"

‎tests/stderr_only_failure_test.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,25 @@ fn lint_issues_are_summarised_and_exit_zero() {
183183
"exit 1 means issues found, which RTK reports without failing"
184184
);
185185
}
186+
187+
/// git answers `-h` with its usage on **stdout** and exit 129. Once `rtk git
188+
/// log -h` / `rtk git status -h` forward the flag (they used to be clap's), the
189+
/// filters' failure branches must hand that stdout back, not just stderr.
190+
#[test]
191+
fn forwarded_help_keeps_gits_stdout_usage() {
192+
let dir = tempfile::tempdir().expect("tempdir");
193+
fake_tool(
194+
dir.path(),
195+
"git",
196+
"case \"$*\" in *-h*|*--help*) echo \"usage: git $1 [<options>]\"; exit 129;; esac; exit 0",
197+
);
198+
for sub in ["log", "status"] {
199+
let out = rtk_with(dir.path(), &["git", sub, "-h"]);
200+
let stdout = String::from_utf8_lossy(&out.stdout);
201+
assert_eq!(out.status.code(), Some(129), "git {sub} -h exit");
202+
assert!(
203+
stdout.contains(&format!("usage: git {sub}")),
204+
"git {sub} -h usage must reach stdout, got {stdout:?}"
205+
);
206+
}
207+
}

0 commit comments

Comments
 (0)