feat(dispatch): walk subcommands and detect revset flag from help

Two real failures motivated this:

- `jjf bm main` (where `bm` is a user alias for `bookmark move`) ran
  `jj bm main -r <id>` — but bookmark move uses `--to`, not `-r`.
- `jjf tag set v0.2.0` errored with "doesn't take revisions" because
  jjf only inspected `jj tag --help` (parent) instead of walking
  into the `set` leaf, which does take `-r`.

Replace the boolean `supports_revisions()` (a substring match on
"REVSET") with `RevsetTarget { leaf, passthrough, flag }`:

- Walks multi-level subcommands by parsing each parent's `Commands:`
  block — including `[aliases: …]` annotations, so `jjf tag s` walks
  to `tag set`. Aliases like `bm` work transparently because `jj bm
  --help` already emits the resolved leaf's help.
- Parses the leaf's `Options:` block and picks the revset flag in
  priority order: `-r`/`--revision[s]` first, then `-t, --to`.
  `--from` is intentionally not a fallback (different semantics from
  "the revision the picker chose").
- Bypasses the picker entirely when the user already passed a revset
  flag in their args, so `jjf describe -r @-` runs verbatim instead
  of double-injecting `-r`.

`exec` and `command_line` now take `&RevsetTarget` and emit the
resolved flag; signatures thread through `tui.rs` and `main.rs`.

New unit tests cover the help parsers and command-line construction
against captured fixtures of `jj tag set --help`, `jj bookmark move
--help`, and `jj tag --help`.
This commit is contained in:
swaits committed 2026-05-04 12:58:14 -06:00
1 parent 1c4ae09646
commit 6ce9cb2b17
3 files changed
+471 -50

No files matched your search

+48 -20
View File
@@ -25,19 +25,19 @@ fn real_main() -> Result<u8> {
match first.as_str() {
"init" => run_init(args),
"--emit" => match args.next() {
Some(sub) => {
let passthrough: Vec<String> = args.collect();
run_pick(&sub, &passthrough, /* emit = */ true)
}
None => {
"--emit" => {
let rest: Vec<String> = args.collect();
if rest.is_empty() {
eprintln!("usage: jjf --emit <jj-subcommand> [args...]");
Ok(2)
return Ok(2);
}
},
sub => {
let passthrough: Vec<String> = args.collect();
run_pick(sub, &passthrough, /* emit = */ false)
run_pick(&rest, /* emit = */ true)
}
_ => {
let mut all = Vec::with_capacity(1 + args.size_hint().0);
all.push(first);
all.extend(args);
run_pick(&all, /* emit = */ false)
}
}
}
@@ -65,10 +65,29 @@ fn run_init(mut args: impl Iterator<Item = String>) -> Result<u8> {
}
}
fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8> {
if !jj::supports_revisions(subcommand)? {
fn run_pick(args: &[String], emit: bool) -> Result<u8> {
let target = jj::resolve_target(args)?;
// Case 1: user already supplied a revset flag — bypass the picker entirely
// and run their command verbatim. Avoids double-`-r` and respects what
// they explicitly asked for.
if has_user_revset_flag(&target.passthrough) {
if emit {
let cmd = jj::command_line(&target, &[]);
println!("{cmd}");
return Ok(0);
}
echo_command(&target, &[]);
let status = jj::exec(&target, &[])?;
return Ok(status.code().unwrap_or(1) as u8);
}
// Case 2: leaf accepts no revset flag at all (e.g. `jj operation`,
// `jj workspace list`). Refuse to run the picker.
if target.flag.is_none() {
let leaf = target.leaf.join(" ");
eprintln!(
"jjf: 'jj {subcommand}' doesn't take revisions — run 'jj {subcommand}' directly."
"jjf: 'jj {leaf}' takes no revset flag (-r/--to) — run 'jj {leaf}' directly."
);
return Ok(2);
}
@@ -89,7 +108,7 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
None
};
let tui_result = tui::run(rows, subcommand, passthrough);
let tui_result = tui::run(rows, &target);
if let Some(saved) = saved_stdout {
restore_stdout(saved)?;
@@ -103,16 +122,25 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
}
if emit {
let cmd = jj::command_line(subcommand, passthrough, &ids);
let cmd = jj::command_line(&target, &ids);
println!("{cmd}");
Ok(0)
} else {
echo_command(subcommand, passthrough, &ids);
let status = jj::exec(subcommand, passthrough, &ids)?;
echo_command(&target, &ids);
let status = jj::exec(&target, &ids)?;
Ok(status.code().unwrap_or(1) as u8)
}
}
fn has_user_revset_flag(passthrough: &[String]) -> bool {
passthrough.iter().any(|a| {
matches!(
a.as_str(),
"-r" | "--revision" | "--revisions" | "-t" | "--to"
)
})
}
#[cfg(unix)]
fn redirect_stdout_to_tty() -> Result<libc::c_int> {
use std::os::fd::AsRawFd;
@@ -142,9 +170,9 @@ fn restore_stdout(saved: libc::c_int) -> Result<()> {
Ok(())
}
fn echo_command(subcommand: &str, passthrough: &[String], ids: &[String]) {
fn echo_command(target: &jj::RevsetTarget, ids: &[String]) {
use std::io::IsTerminal;
let cmd = jj::command_line(subcommand, passthrough, ids);
let cmd = jj::command_line(target, ids);
if std::io::stdout().is_terminal() {
println!("\x1b[2m$\x1b[0m {cmd}");
} else {