From 60bee49c5ff142371bba27b3b4dfd3be6f0d016c Mon Sep 17 00:00:00 2001 From: Dmitry Torokhov Date: Tue, 29 Sep 2026 14:58:16 -0700 Subject: [PATCH 1/4] feat: allow unambiguous command shorthands Allow invoking StGit commands, aliases, and subcommands using any unambiguous prefix (for example, `stg del` for `stg delete`, `stg ser` for `stg series`, or `stg em s` for `stg email send`), matching StGit 1.x behavior. Enable `infer_subcommands(true)` on the base clap Command so clap accepts unambiguous prefixes for top-level and nested subcommands. Because the initial bootstrap CLI parser uses `allow_external_subcommands(true)` to avoid constructing the full command graph up front, also resolve subcommand prefixes across built-in commands, `help`, and configured aliases: - Exact matches on built-in commands, `help`, or aliases always take precedence over prefix matches. - Non-exact prefixes are checked against built-in commands, `help`, and aliases together so that a prefix shared between a built-in command and an alias (such as `res` for `reset` and `resolved`) is rejected as ambiguous. Signed-off-by: Dmitry Torokhov --- src/main.rs | 132 +++++++++++++++++++++++++++++++++++++++++------ t/t0004-main.sh | 29 ++++++++++- t/t0010-alias.sh | 5 +- t/t1901-email.sh | 4 +- 4 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7071d8a2..3abac943 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ fn get_base_command(color_choice: Option) -> clap::Comma ], )) .help_expected(true) + .infer_subcommands(true) .max_term_width(88) .disable_version_flag(true) .arg( @@ -140,6 +141,44 @@ pub(crate) fn get_full_command( .subcommands(aliases.values().map(alias::Alias::make)) } +enum Subcommand<'a> { + Command(&'static cmd::StGitCommand), + Alias(&'a alias::Alias), + Help, +} + +/// Find a command, alias, or `"help"` matching `sub_name` exactly or as an +/// unambiguous prefix. +fn find_subcommand<'a>(sub_name: &str, aliases: &'a alias::Aliases) -> Option> { + if let Some(command) = STGIT_COMMANDS + .iter() + .find(|command| command.name == sub_name) + { + return Some(Subcommand::Command(command)); + } else if sub_name == "help" { + return Some(Subcommand::Help); + } else if let Some(alias) = aliases.get(sub_name) { + return Some(Subcommand::Alias(alias)); + } + + let mut candidates = STGIT_COMMANDS + .iter() + .filter(|command| command.name.starts_with(sub_name)) + .map(Subcommand::Command) + .chain( + aliases + .values() + .filter(|alias| alias.name.starts_with(sub_name)) + .map(Subcommand::Alias), + ) + .chain("help".starts_with(sub_name).then_some(Subcommand::Help)); + + match (candidates.next(), candidates.next()) { + (Some(subcommand), None) => Some(subcommand), + _ => None, + } +} + /// Main entry point for `stg` executable. /// /// The name of the game is to dispatch to the appropriate subcommand or alias as @@ -187,16 +226,19 @@ fn main() -> ! { // If the subcommand name does not match a builtin subcommand, the // aliases are located, which involves finding the Git repo and parsing // the various levels of config files. If the subcommand name matches an - // alias, it is executed and the cost of constructing all subcommands' - // Command instances is still avoided. + // alias or an unambiguous prefix of a subcommand or alias, it is + // executed and the cost of constructing all subcommands' Command + // instances is still avoided. match get_aliases() { Err(e) => exit_with_result(Err(e), color_choice), - Ok((aliases, maybe_repo)) => { - if let Some(alias) = aliases.get(sub_name) { + Ok((aliases, maybe_repo)) => match find_subcommand(sub_name, &aliases) { + Some(Subcommand::Command(command)) => { + execute_command(command, argv, color_choice) + } + Some(Subcommand::Alias(alias)) => { let user_args: Vec = sub_matches .get_many::("") .map_or_else(Vec::new, |vals| vals.cloned().collect()); - match alias.kind { alias::AliasKind::Shell => execute_shell_alias( alias, @@ -212,7 +254,8 @@ fn main() -> ! { &aliases, ), } - } else { + } + Some(Subcommand::Help) | None => { // If no command or alias matches can be determined from the // above process, then a complete clap::Command instance is // constructed with all subcommand Command instances for @@ -221,7 +264,7 @@ fn main() -> ! { // expected to terminate with an appropriate help message. full_app_help(argv, Some(aliases), color_choice) } - } + }, } } } else { @@ -502,18 +545,13 @@ fn execute_stgit_alias( .expect("empty aliases are filtered in get_aliases()") .as_str(); - if let Some(command) = STGIT_COMMANDS - .iter() - .find(|command| command.name == resolved_cmd_name) - { - execute_command(command, argv, color_choice) - } else if aliases.contains_key(resolved_cmd_name) { - Err(anyhow!("recursive alias `{}`", alias.name)) - } else { - Err(anyhow!( + match find_subcommand(resolved_cmd_name, aliases) { + Some(Subcommand::Command(command)) => execute_command(command, argv, color_choice), + Some(Subcommand::Alias(_)) => Err(anyhow!("recursive alias `{}`", alias.name)), + Some(Subcommand::Help) | None => Err(anyhow!( "bad alias for `{}`: `{resolved_cmd_name}` is not a stg command", alias.name, - )) + )), } } Err(reason) => Err(anyhow!("bad alias for `{}`: {reason}", alias.name)), @@ -643,3 +681,63 @@ fn print_merge_conflicts() { }; stupid.status_short(Some(pathspecs)).unwrap_or_default(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subcommand_shorthand_resolution() { + let mut aliases = alias::get_default_aliases(); + assert!(matches!( + find_subcommand("del", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "delete" + )); + assert!(matches!( + find_subcommand("de", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "delete" + )); + assert!(matches!( + find_subcommand("di", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "diff" + )); + assert!(find_subcommand("d", &aliases).is_none()); + assert!(find_subcommand("pu", &aliases).is_none()); + assert!(find_subcommand("res", &aliases).is_none()); + assert!(matches!( + find_subcommand("rese", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "reset" + )); + assert!(matches!( + find_subcommand("reso", &aliases), + Some(Subcommand::Alias(alias)) if alias.name == "resolved" + )); + assert!(matches!( + find_subcommand("st", &aliases), + Some(Subcommand::Alias(alias)) if alias.name == "status" + )); + assert!(find_subcommand("h", &aliases).is_none()); + assert!(matches!( + find_subcommand("he", &aliases), + Some(Subcommand::Help) + )); + assert!(matches!( + find_subcommand("hi", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "hide" + )); + + aliases.insert( + "show-stat".to_string(), + alias::Alias::new("show-stat", "!git show --stat"), + ); + assert!(find_subcommand("sho", &aliases).is_none()); + assert!(matches!( + find_subcommand("show", &aliases), + Some(Subcommand::Command(cmd)) if cmd.name == "show" + )); + assert!(matches!( + find_subcommand("show-", &aliases), + Some(Subcommand::Alias(alias)) if alias.name == "show-stat" + )); + } +} diff --git a/t/t0004-main.sh b/t/t0004-main.sh index e8b8afab..11eb54bb 100755 --- a/t/t0004-main.sh +++ b/t/t0004-main.sh @@ -24,12 +24,39 @@ test_expect_success 'Test help on regular command' ' stg help init | grep -e "stg init" ' +test_expect_success 'Test help on command shorthand' ' + stg in -h >h-in.txt && + stg init -h >h-init.txt && + test_cmp h-init.txt h-in.txt && + stg he delete >h-del.txt && + stg delete --help >h-delete.txt && + test_cmp h-delete.txt h-del.txt +' + +test_expect_success 'Test unambiguous command shorthand' ' + stg ver >v0.txt && + stg version >v1.txt && + test_cmp v1.txt v0.txt && + stg in && + stg new -m p0 && + stg ser --noprefix >s.txt && + echo p0 >expected.txt && + test_cmp expected.txt s.txt && + stg del p0 && + stg ser --noprefix >s.txt && + test_must_be_empty s.txt +' + test_expect_success 'Test help on ambiguous command' ' general_error stg pu 2>err && grep -e "unrecognized subcommand .pu." err && grep -e "some similar subcommands exist: .pull., .push." err && general_error stg help pu 2>err && - grep -e "unrecognized subcommand .pu." err + grep -e "unrecognized subcommand .pu." err && + general_error stg d 2>err && + grep -e "unrecognized subcommand .d." err && + general_error stg h 2>err && + grep -e "unrecognized subcommand .h." err ' test_expect_success 'Test version/--version equivalence' ' diff --git a/t/t0010-alias.sh b/t/t0010-alias.sh index d83ac6e1..75d4d3c8 100755 --- a/t/t0010-alias.sh +++ b/t/t0010-alias.sh @@ -12,10 +12,13 @@ test_expect_success 'Test help on builtin alias command' ' test_expect_success 'Test ambiguous alias' ' test_config stgit.alias.show-stat "!git show --stat" && stg show-stat && + stg show-s && stg init && stg show && general_error stg sho 2>err && - grep -e "tip: some similar subcommands exist: .show-stat., .show." err + grep -e "tip: some similar subcommands exist: .show-stat., .show." err && + general_error stg res 2>err && + grep -e "unrecognized subcommand .res." err ' test_expect_success 'Setup top-level and nested aliases' ' diff --git a/t/t1901-email.sh b/t/t1901-email.sh index d4089151..1f71e356 100755 --- a/t/t1901-email.sh +++ b/t/t1901-email.sh @@ -11,7 +11,9 @@ test_expect_success 'Check that email command requires subcommand' ' test_expect_success 'Check email subcommand help' ' stg email help format && - stg email help send + stg email help send && + stg em f -h && + stg em s --help ' test_done From dbe8973457efd95491ba468a59697339b5f1d1ca Mon Sep 17 00:00:00 2001 From: Peter Grayson Date: Sat, 10 Oct 2026 14:11:39 -0400 Subject: [PATCH 2/4] feat: make command prefix inference opt-in Whether a prefix is unambiguous depends on the set of commands and aliases, so defining an alias or upgrading StGit can silently break an abbreviation that previously worked, and the result can differ between repositories with different local aliases. Users who accept that tradeoff can enable stgit.infer-commands; everyone else keeps the exact-match behavior of StGit 2.x. The config is only read on the fast path when the matched command has nested subcommands, so plain command dispatch stays cheap. --- Documentation/stg.txt | 11 ++++ src/main.rs | 147 ++++++++++++++++++++++++++++++++---------- t/t0004-main.sh | 23 +++++++ t/t0010-alias.sh | 7 +- t/t1901-email.sh | 9 ++- 5 files changed, 160 insertions(+), 37 deletions(-) diff --git a/Documentation/stg.txt b/Documentation/stg.txt index 04de2067..844b017a 100644 --- a/Documentation/stg.txt +++ b/Documentation/stg.txt @@ -307,6 +307,17 @@ stgit.import.message-id:: When set to 'true', create 'Message-ID:' trailer in the patch description of patches imported from email using linkstg:import[]. +stgit.infer-commands:: + When set to 'true', StGit commands and aliases may be invoked using any unambiguous + prefix of their names. For example, `stg ser` runs `stg series`. Exact command and + alias names always take precedence over prefixes. Defaults to 'false'. ++ +N.B. whether a prefix is unambiguous depends on the set of defined aliases as well as +the set of StGit commands. Defining a new alias, or upgrading to a version of StGit with +new commands, may cause a previously unambiguous prefix to become ambiguous. Explicit +aliases (see 'stgit.alias.*') are a more stable alternative for abbreviating commonly +used commands. + stgit.keepoptimized:: When set to 'true', after pulling changes with linkstg:pull[], the repository's object database will be optimized by running linkgit:git-repack[1]. diff --git a/src/main.rs b/src/main.rs index 3abac943..953fbc7e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,7 +62,6 @@ fn get_base_command(color_choice: Option) -> clap::Comma ], )) .help_expected(true) - .infer_subcommands(true) .max_term_width(88) .disable_version_flag(true) .arg( @@ -147,9 +146,13 @@ enum Subcommand<'a> { Help, } -/// Find a command, alias, or `"help"` matching `sub_name` exactly or as an -/// unambiguous prefix. -fn find_subcommand<'a>(sub_name: &str, aliases: &'a alias::Aliases) -> Option> { +/// Find a command, alias, or `"help"` matching `sub_name` exactly or, when +/// `infer_commands` is enabled, as an unambiguous prefix. +fn find_subcommand<'a>( + sub_name: &str, + aliases: &'a alias::Aliases, + infer_commands: bool, +) -> Option> { if let Some(command) = STGIT_COMMANDS .iter() .find(|command| command.name == sub_name) @@ -159,6 +162,8 @@ fn find_subcommand<'a>(sub_name: &str, aliases: &'a alias::Aliases) -> Option ! { &self::cmd::version::STGIT_COMMAND, vec![argv[0].clone(), OsString::from("version")], color_choice, + Some(false), ) } else if let Err(e) = change_directories(&matches) { exit_with_result(Err(e), color_choice) } else if matches.get_flag("help-option") { - full_app_help(argv, None, color_choice) + full_app_help(argv, None, false, color_choice) } else if let Some((sub_name, sub_matches)) = matches.subcommand() { // If the name matches any known subcommands, then only the Command for that // particular command is constructed and the costs of searching for aliases @@ -221,19 +227,23 @@ fn main() -> ! { .iter() .find(|command| command.name == sub_name) { - execute_command(command, argv, color_choice) + execute_command(command, argv, color_choice, None) } else { // If the subcommand name does not match a builtin subcommand, the // aliases are located, which involves finding the Git repo and parsing // the various levels of config files. If the subcommand name matches an - // alias or an unambiguous prefix of a subcommand or alias, it is - // executed and the cost of constructing all subcommands' Command - // instances is still avoided. - match get_aliases() { + // alias, or an unambiguous prefix of a subcommand or alias when + // stgit.infer-commands is enabled, it is executed and the cost of + // constructing all subcommands' Command instances is still avoided. + match get_dispatch_config() { Err(e) => exit_with_result(Err(e), color_choice), - Ok((aliases, maybe_repo)) => match find_subcommand(sub_name, &aliases) { + Ok(DispatchConfig { + aliases, + infer_commands, + repo: maybe_repo, + }) => match find_subcommand(sub_name, &aliases, infer_commands) { Some(Subcommand::Command(command)) => { - execute_command(command, argv, color_choice) + execute_command(command, argv, color_choice, Some(infer_commands)) } Some(Subcommand::Alias(alias)) => { let user_args: Vec = sub_matches @@ -252,6 +262,7 @@ fn main() -> ! { user_args, color_choice, &aliases, + infer_commands, ), } } @@ -262,18 +273,18 @@ fn main() -> ! { // each subcommand and alias. The command line is then // re-processed by this full-blown Command instance which is // expected to terminate with an appropriate help message. - full_app_help(argv, Some(aliases), color_choice) + full_app_help(argv, Some(aliases), infer_commands, color_choice) } }, } } } else { - full_app_help(argv, None, color_choice) + full_app_help(argv, None, false, color_choice) } } else { // -C options are not processed in this branch. This is okay because clap's // error message will not include aliases (which depend on -C). - full_app_help(argv, None, color_choice) + full_app_help(argv, None, false, color_choice) } } @@ -341,9 +352,13 @@ fn change_directories(matches: &ArgMatches) -> Result<()> { /// that `argv` was previously determined to be invalid. The full command can then /// output a help message with a complete view of all subcommands and aliases, and then /// terminate to process. +/// +/// `infer_commands` must agree with how `argv` was previously evaluated so that clap +/// does not find a viable match where [`find_subcommand()`] did not. fn full_app_help( argv: Vec, aliases: Option, + infer_commands: bool, color_choice: Option, ) -> ! { let aliases = if let Some(aliases) = aliases { @@ -359,7 +374,7 @@ fn full_app_help( // of existing subcommands. We group the commands by their categories and create several // shorter lists which are easier to read. let command = { - let command = get_full_command(&aliases, color_choice); + let command = get_full_command(&aliases, color_choice).infer_subcommands(infer_commands); let heading_style = command.get_styles().get_header().render(); let heading_style_reset = command.get_styles().get_header().render_reset(); let mut subcommands_by_category = String::new(); @@ -432,13 +447,29 @@ fn full_app_help( /// /// N.B. a new top-level app instance is created to ensure that help messages are /// formatted using the correct executable path (from `argv[0]`). +/// +/// `infer_commands` determines whether the subcommand and any nested subcommands may be +/// abbreviated. When `None`, the configuration is only consulted if the subcommand has +/// nested subcommands of its own. fn execute_command( command: &cmd::StGitCommand, argv: Vec, color_choice: Option, + infer_commands: Option, ) -> ! { + let subcommand = (command.make)(); + let infer_commands = match infer_commands { + Some(infer_commands) => infer_commands, + None if subcommand.has_subcommands() => match get_dispatch_config() { + Ok(config) => config.infer_commands, + Err(e) => exit_with_result(Err(e), color_choice), + }, + None => false, + }; + match get_base_command(color_choice) - .subcommand((command.make)()) + .infer_subcommands(infer_commands) + .subcommand(subcommand) .try_get_matches_from(argv) { Ok(top_matches) => { @@ -524,6 +555,7 @@ fn execute_stgit_alias( user_args: Vec, color_choice: Option, aliases: &alias::Aliases, + infer_commands: bool, ) -> ! { let result = match alias.split() { Ok(alias_args) => { @@ -545,8 +577,10 @@ fn execute_stgit_alias( .expect("empty aliases are filtered in get_aliases()") .as_str(); - match find_subcommand(resolved_cmd_name, aliases) { - Some(Subcommand::Command(command)) => execute_command(command, argv, color_choice), + match find_subcommand(resolved_cmd_name, aliases, infer_commands) { + Some(Subcommand::Command(command)) => { + execute_command(command, argv, color_choice, Some(infer_commands)) + } Some(Subcommand::Alias(_)) => Err(anyhow!("recursive alias `{}`", alias.name)), Some(Subcommand::Help) | None => Err(anyhow!( "bad alias for `{}`: `{resolved_cmd_name}` is not a stg command", @@ -560,6 +594,15 @@ fn execute_stgit_alias( exit_with_result(result, color_choice) } +/// Configuration affecting how subcommands and aliases are dispatched. +struct DispatchConfig { + aliases: alias::Aliases, + /// Whether unambiguous prefixes of subcommand and alias names are accepted + /// (`stgit.infer-commands`). + infer_commands: bool, + repo: Option, +} + /// Get aliases mapping. /// /// Since aliases are defined in git config files, an attempt is made to open a repo so @@ -569,6 +612,14 @@ fn execute_stgit_alias( /// N.B. the outcome of this alias search depends on the current directory and thus /// depends on -C options having been previously processed. pub(crate) fn get_aliases() -> Result<(alias::Aliases, Option)> { + let DispatchConfig { aliases, repo, .. } = get_dispatch_config()?; + Ok((aliases, repo)) +} + +/// Get aliases and other dispatch-related configuration. +/// +/// See [`get_aliases()`] regarding how the configuration is located. +fn get_dispatch_config() -> Result { let maybe_repo = gix::Repository::open().ok(); let maybe_config = maybe_repo.as_ref().map(|repo| repo.config_snapshot()); let config_file = maybe_config.as_ref().map(|snapshot| snapshot.plumbing()); @@ -582,7 +633,17 @@ pub(crate) fn get_aliases() -> Result<(alias::Aliases, Option)> let aliases = alias::get_aliases(config_file, |name| { STGIT_COMMANDS.iter().any(|command| command.name == name) || name == "help" })?; - Ok((aliases, maybe_repo)) + let infer_commands = config_file + .map_or(Ok(None), |config_file| { + config_file.boolean("stgit.infer-commands") + }) + .map_err(|e| anyhow!("invalid config value `stgit.infer-commands`: {e}"))? + .unwrap_or(false); + Ok(DispatchConfig { + aliases, + infer_commands, + repo: maybe_repo, + }) } /// Print user-facing message to stderr. @@ -689,40 +750,56 @@ mod tests { #[test] fn subcommand_shorthand_resolution() { let mut aliases = alias::get_default_aliases(); + assert!(find_subcommand("del", &aliases, false).is_none()); + assert!(find_subcommand("st", &aliases, false).is_none()); + assert!(find_subcommand("he", &aliases, false).is_none()); + assert!(matches!( + find_subcommand("delete", &aliases, false), + Some(Subcommand::Command(cmd)) if cmd.name == "delete" + )); + assert!(matches!( + find_subcommand("status", &aliases, false), + Some(Subcommand::Alias(alias)) if alias.name == "status" + )); + assert!(matches!( + find_subcommand("help", &aliases, false), + Some(Subcommand::Help) + )); + assert!(matches!( - find_subcommand("del", &aliases), + find_subcommand("del", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "delete" )); assert!(matches!( - find_subcommand("de", &aliases), + find_subcommand("de", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "delete" )); assert!(matches!( - find_subcommand("di", &aliases), + find_subcommand("di", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "diff" )); - assert!(find_subcommand("d", &aliases).is_none()); - assert!(find_subcommand("pu", &aliases).is_none()); - assert!(find_subcommand("res", &aliases).is_none()); + assert!(find_subcommand("d", &aliases, true).is_none()); + assert!(find_subcommand("pu", &aliases, true).is_none()); + assert!(find_subcommand("res", &aliases, true).is_none()); assert!(matches!( - find_subcommand("rese", &aliases), + find_subcommand("rese", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "reset" )); assert!(matches!( - find_subcommand("reso", &aliases), + find_subcommand("reso", &aliases, true), Some(Subcommand::Alias(alias)) if alias.name == "resolved" )); assert!(matches!( - find_subcommand("st", &aliases), + find_subcommand("st", &aliases, true), Some(Subcommand::Alias(alias)) if alias.name == "status" )); - assert!(find_subcommand("h", &aliases).is_none()); + assert!(find_subcommand("h", &aliases, true).is_none()); assert!(matches!( - find_subcommand("he", &aliases), + find_subcommand("he", &aliases, true), Some(Subcommand::Help) )); assert!(matches!( - find_subcommand("hi", &aliases), + find_subcommand("hi", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "hide" )); @@ -730,13 +807,13 @@ mod tests { "show-stat".to_string(), alias::Alias::new("show-stat", "!git show --stat"), ); - assert!(find_subcommand("sho", &aliases).is_none()); + assert!(find_subcommand("sho", &aliases, true).is_none()); assert!(matches!( - find_subcommand("show", &aliases), + find_subcommand("show", &aliases, true), Some(Subcommand::Command(cmd)) if cmd.name == "show" )); assert!(matches!( - find_subcommand("show-", &aliases), + find_subcommand("show-", &aliases, true), Some(Subcommand::Alias(alias)) if alias.name == "show-stat" )); } diff --git a/t/t0004-main.sh b/t/t0004-main.sh index 11eb54bb..ed7f155b 100755 --- a/t/t0004-main.sh +++ b/t/t0004-main.sh @@ -24,7 +24,26 @@ test_expect_success 'Test help on regular command' ' stg help init | grep -e "stg init" ' +test_expect_success 'Test command shorthand disabled by default' ' + general_error stg ver 2>err && + grep -e "unrecognized subcommand .ver." err && + general_error stg he delete 2>err && + grep -e "unrecognized subcommand .he." err && + general_error stg st 2>err && + grep -e "unrecognized subcommand .st." err && + test_config stgit.infer-commands false && + general_error stg ver 2>err && + grep -e "unrecognized subcommand .ver." err +' + +test_expect_success 'Test invalid stgit.infer-commands value' ' + test_config stgit.infer-commands bogus && + command_error stg ver 2>err && + grep -e "invalid config value .stgit.infer-commands." err +' + test_expect_success 'Test help on command shorthand' ' + test_config stgit.infer-commands true && stg in -h >h-in.txt && stg init -h >h-init.txt && test_cmp h-init.txt h-in.txt && @@ -34,6 +53,7 @@ test_expect_success 'Test help on command shorthand' ' ' test_expect_success 'Test unambiguous command shorthand' ' + test_config stgit.infer-commands true && stg ver >v0.txt && stg version >v1.txt && test_cmp v1.txt v0.txt && @@ -53,6 +73,9 @@ test_expect_success 'Test help on ambiguous command' ' grep -e "some similar subcommands exist: .pull., .push." err && general_error stg help pu 2>err && grep -e "unrecognized subcommand .pu." err && + test_config stgit.infer-commands true && + general_error stg pu 2>err && + grep -e "some similar subcommands exist: .pull., .push." err && general_error stg d 2>err && grep -e "unrecognized subcommand .d." err && general_error stg h 2>err && diff --git a/t/t0010-alias.sh b/t/t0010-alias.sh index 75d4d3c8..34b97817 100755 --- a/t/t0010-alias.sh +++ b/t/t0010-alias.sh @@ -12,11 +12,16 @@ test_expect_success 'Test help on builtin alias command' ' test_expect_success 'Test ambiguous alias' ' test_config stgit.alias.show-stat "!git show --stat" && stg show-stat && - stg show-s && + general_error stg show-s 2>err && + grep -e "unrecognized subcommand .show-s." err && stg init && stg show && general_error stg sho 2>err && grep -e "tip: some similar subcommands exist: .show-stat., .show." err && + test_config stgit.infer-commands true && + stg show-s && + general_error stg sho 2>err && + grep -e "tip: some similar subcommands exist: .show-stat., .show." err && general_error stg res 2>err && grep -e "unrecognized subcommand .res." err ' diff --git a/t/t1901-email.sh b/t/t1901-email.sh index 1f71e356..523ea6d5 100755 --- a/t/t1901-email.sh +++ b/t/t1901-email.sh @@ -11,7 +11,14 @@ test_expect_success 'Check that email command requires subcommand' ' test_expect_success 'Check email subcommand help' ' stg email help format && - stg email help send && + stg email help send +' + +test_expect_success 'Check email subcommand shorthand' ' + general_error stg email f -h 2>err && + grep -e "unrecognized subcommand .f." err && + test_config stgit.infer-commands true && + stg email f -h && stg em f -h && stg em s --help ' From 443919085aac2586ee83991bd9cd4076c92fee04 Mon Sep 17 00:00:00 2001 From: Peter Grayson Date: Sat, 10 Oct 2026 14:15:38 -0400 Subject: [PATCH 3/4] feat: require exact command names in alias definitions An alias stored in config that relies on a command prefix breaks as soon as another command or alias sharing that prefix appears, far from where the alias was defined. Requiring the exact command name keeps configured aliases stable regardless of stgit.infer-commands. Nested subcommands are still inferred when enabled since they may come from the user's arguments rather than the alias definition. --- Documentation/stg.txt | 3 ++- src/main.rs | 7 ++++++- t/t0010-alias.sh | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Documentation/stg.txt b/Documentation/stg.txt index 844b017a..209187f3 100644 --- a/Documentation/stg.txt +++ b/Documentation/stg.txt @@ -310,7 +310,8 @@ stgit.import.message-id:: stgit.infer-commands:: When set to 'true', StGit commands and aliases may be invoked using any unambiguous prefix of their names. For example, `stg ser` runs `stg series`. Exact command and - alias names always take precedence over prefixes. Defaults to 'false'. + alias names always take precedence over prefixes. Prefixes are not accepted in alias + definitions; aliases must name their StGit command exactly. Defaults to 'false'. + N.B. whether a prefix is unambiguous depends on the set of defined aliases as well as the set of StGit commands. Defining a new alias, or upgrading to a version of StGit with diff --git a/src/main.rs b/src/main.rs index 953fbc7e..116826c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -549,6 +549,11 @@ fn execute_shell_alias( /// Execute alias to StGit command. /// /// Recursive aliases are detected. +/// +/// The alias must name its StGit command exactly, regardless of `infer_commands`, so +/// that the meaning of a configured alias cannot change as commands or other aliases +/// are added. `infer_commands` still applies to any nested subcommand since it may come +/// from the user's arguments. fn execute_stgit_alias( alias: &alias::Alias, exec_path: &OsString, @@ -577,7 +582,7 @@ fn execute_stgit_alias( .expect("empty aliases are filtered in get_aliases()") .as_str(); - match find_subcommand(resolved_cmd_name, aliases, infer_commands) { + match find_subcommand(resolved_cmd_name, aliases, false) { Some(Subcommand::Command(command)) => { execute_command(command, argv, color_choice, Some(infer_commands)) } diff --git a/t/t0010-alias.sh b/t/t0010-alias.sh index 34b97817..c4581be1 100755 --- a/t/t0010-alias.sh +++ b/t/t0010-alias.sh @@ -26,6 +26,15 @@ test_expect_success 'Test ambiguous alias' ' grep -e "unrecognized subcommand .res." err ' +test_expect_success 'Test alias definition requires exact command name' ' + test_config stgit.infer-commands true && + test_config stgit.alias.ls "ser" && + command_error stg ls 2>err && + grep -e "bad alias for .ls.: .ser. is not a stg command" err && + test_config stgit.alias.ls "series" && + stg ls +' + test_expect_success 'Setup top-level and nested aliases' ' test_create_repo foo/bar/baz && git config --local stgit.alias.top-level-alias "!echo TOP-LEVEL-ALIAS" && From 5d2f36078be1058845c948a00db4d84dea55ebce Mon Sep 17 00:00:00 2001 From: Peter Grayson Date: Sat, 10 Oct 2026 14:21:19 -0400 Subject: [PATCH 4/4] fix: never abbreviate long-option-like subcommands The flag-style subcommands of stg branch looked like long options, so stg branch --l listed branches even though real long options are never abbreviated. Clap's subcommand inference is a global setting that cannot be enabled for the top-level command alone, so an inferred top-level name is now spelled out in argv before clap sees it. Inference is then only enabled for commands whose nested subcommands do not start with a hyphen. Spelling out an inferred "help" the same way means the full-help fallback never needs inference. --- src/main.rs | 71 +++++++++++++++++++++++++++++++++++-------------- t/t0004-main.sh | 11 ++++++++ 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 116826c7..ef19e487 100644 --- a/src/main.rs +++ b/src/main.rs @@ -218,7 +218,7 @@ fn main() -> ! { } else if let Err(e) = change_directories(&matches) { exit_with_result(Err(e), color_choice) } else if matches.get_flag("help-option") { - full_app_help(argv, None, false, color_choice) + full_app_help(argv, None, color_choice) } else if let Some((sub_name, sub_matches)) = matches.subcommand() { // If the name matches any known subcommands, then only the Command for that // particular command is constructed and the costs of searching for aliases @@ -243,6 +243,11 @@ fn main() -> ! { repo: maybe_repo, }) => match find_subcommand(sub_name, &aliases, infer_commands) { Some(Subcommand::Command(command)) => { + // An inferred command name is spelled out so that clap + // need not infer it. Clap's inference would otherwise also + // apply to all nested subcommands. + let mut argv = argv; + spell_out_subcommand(&mut argv, sub_matches, command.name); execute_command(command, argv, color_choice, Some(infer_commands)) } Some(Subcommand::Alias(alias)) => { @@ -266,28 +271,47 @@ fn main() -> ! { ), } } - Some(Subcommand::Help) | None => { + Some(Subcommand::Help) => { + // An inferred "help" is spelled out since the full Command + // does not infer subcommand names. + let mut argv = argv; + spell_out_subcommand(&mut argv, sub_matches, "help"); + full_app_help(argv, Some(aliases), color_choice) + } + None => { // If no command or alias matches can be determined from the // above process, then a complete clap::Command instance is // constructed with all subcommand Command instances for // each subcommand and alias. The command line is then // re-processed by this full-blown Command instance which is // expected to terminate with an appropriate help message. - full_app_help(argv, Some(aliases), infer_commands, color_choice) + full_app_help(argv, Some(aliases), color_choice) } }, } } } else { - full_app_help(argv, None, false, color_choice) + full_app_help(argv, None, color_choice) } } else { // -C options are not processed in this branch. This is okay because clap's // error message will not include aliases (which depend on -C). - full_app_help(argv, None, false, color_choice) + full_app_help(argv, None, color_choice) } } +/// Replace the (possibly abbreviated) external subcommand name in `argv` with `name`. +/// +/// The bootstrap Command treats everything after the subcommand name as the external +/// subcommand's arguments, so the name immediately precedes those arguments. +fn spell_out_subcommand(argv: &mut [OsString], sub_matches: &ArgMatches, name: &str) { + let num_args = sub_matches + .get_many::("") + .map_or(0, |vals| vals.len()); + let index = argv.len() - 1 - num_args; + argv[index] = OsString::from(name); +} + /// Exit the program based on the provided [`Result`]. /// /// Error results from conflicts trigger merge conflicts to be printed and an exit code @@ -353,12 +377,12 @@ fn change_directories(matches: &ArgMatches) -> Result<()> { /// output a help message with a complete view of all subcommands and aliases, and then /// terminate to process. /// -/// `infer_commands` must agree with how `argv` was previously evaluated so that clap -/// does not find a viable match where [`find_subcommand()`] did not. +/// Subcommand names are not inferred; any inferred name must already be spelled out in +/// `argv`. This ensures clap does not find a viable match where [`find_subcommand()`] +/// did not. fn full_app_help( argv: Vec, aliases: Option, - infer_commands: bool, color_choice: Option, ) -> ! { let aliases = if let Some(aliases) = aliases { @@ -374,7 +398,7 @@ fn full_app_help( // of existing subcommands. We group the commands by their categories and create several // shorter lists which are easier to read. let command = { - let command = get_full_command(&aliases, color_choice).infer_subcommands(infer_commands); + let command = get_full_command(&aliases, color_choice); let heading_style = command.get_styles().get_header().render(); let heading_style_reset = command.get_styles().get_header().render_reset(); let mut subcommands_by_category = String::new(); @@ -448,9 +472,12 @@ fn full_app_help( /// N.B. a new top-level app instance is created to ensure that help messages are /// formatted using the correct executable path (from `argv[0]`). /// -/// `infer_commands` determines whether the subcommand and any nested subcommands may be -/// abbreviated. When `None`, the configuration is only consulted if the subcommand has -/// nested subcommands of its own. +/// `infer_commands` determines whether nested subcommands may be abbreviated; the +/// subcommand name in `argv` must be exact. When `None`, the configuration is only +/// consulted if the subcommand has nested subcommands that may be abbreviated. +/// +/// Nested subcommands spelled like long options, e.g. `stg branch --list`, are never +/// abbreviated. fn execute_command( command: &cmd::StGitCommand, argv: Vec, @@ -458,14 +485,18 @@ fn execute_command( infer_commands: Option, ) -> ! { let subcommand = (command.make)(); - let infer_commands = match infer_commands { - Some(infer_commands) => infer_commands, - None if subcommand.has_subcommands() => match get_dispatch_config() { - Ok(config) => config.infer_commands, - Err(e) => exit_with_result(Err(e), color_choice), - }, - None => false, - }; + let has_inferable_subcommands = subcommand.has_subcommands() + && !subcommand + .get_subcommands() + .any(|nested| nested.get_name().starts_with('-')); + let infer_commands = has_inferable_subcommands + && match infer_commands { + Some(infer_commands) => infer_commands, + None => match get_dispatch_config() { + Ok(config) => config.infer_commands, + Err(e) => exit_with_result(Err(e), color_choice), + }, + }; match get_base_command(color_choice) .infer_subcommands(infer_commands) diff --git a/t/t0004-main.sh b/t/t0004-main.sh index ed7f155b..60cb8964 100755 --- a/t/t0004-main.sh +++ b/t/t0004-main.sh @@ -67,6 +67,17 @@ test_expect_success 'Test unambiguous command shorthand' ' test_must_be_empty s.txt ' +test_expect_success 'Test long-option-like subcommands are not abbreviated' ' + test_config stgit.infer-commands true && + general_error stg branch --l 2>err && + grep -e "unexpected argument .--l. found" err && + general_error stg bra --l 2>err && + grep -e "unexpected argument .--l. found" err && + stg bra --list >b0.txt && + stg branch --list >b1.txt && + test_cmp b1.txt b0.txt +' + test_expect_success 'Test help on ambiguous command' ' general_error stg pu 2>err && grep -e "unrecognized subcommand .pu." err &&