diff --git a/Documentation/stg.txt b/Documentation/stg.txt index 04de2067..209187f3 100644 --- a/Documentation/stg.txt +++ b/Documentation/stg.txt @@ -307,6 +307,18 @@ 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. 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 +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 7071d8a2..ef19e487 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,6 +140,50 @@ 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, 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) + { + 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)); + } else if !infer_commands { + return None; + } + + 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 @@ -169,6 +213,7 @@ fn main() -> ! { &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) @@ -182,21 +227,33 @@ 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, 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)) => { - if let Some(alias) = aliases.get(sub_name) { + Ok(DispatchConfig { + aliases, + infer_commands, + 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)) => { 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, @@ -210,9 +267,18 @@ fn main() -> ! { user_args, color_choice, &aliases, + infer_commands, ), } - } else { + } + 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 @@ -221,7 +287,7 @@ fn main() -> ! { // expected to terminate with an appropriate help message. full_app_help(argv, Some(aliases), color_choice) } - } + }, } } } else { @@ -234,6 +300,18 @@ fn main() -> ! { } } +/// 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 @@ -298,6 +376,10 @@ 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. +/// +/// 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, @@ -389,13 +471,36 @@ 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 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, color_choice: Option, + infer_commands: Option, ) -> ! { + let subcommand = (command.make)(); + 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) - .subcommand((command.make)()) + .infer_subcommands(infer_commands) + .subcommand(subcommand) .try_get_matches_from(argv) { Ok(top_matches) => { @@ -475,12 +580,18 @@ 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, user_args: Vec, color_choice: Option, aliases: &alias::Aliases, + infer_commands: bool, ) -> ! { let result = match alias.split() { Ok(alias_args) => { @@ -502,18 +613,15 @@ 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, false) { + 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", alias.name, - )) + )), } } Err(reason) => Err(anyhow!("bad alias for `{}`: {reason}", alias.name)), @@ -522,6 +630,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 @@ -531,6 +648,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()); @@ -544,7 +669,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. @@ -643,3 +778,79 @@ 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!(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, true), + Some(Subcommand::Command(cmd)) if cmd.name == "delete" + )); + assert!(matches!( + find_subcommand("de", &aliases, true), + Some(Subcommand::Command(cmd)) if cmd.name == "delete" + )); + assert!(matches!( + find_subcommand("di", &aliases, true), + Some(Subcommand::Command(cmd)) if cmd.name == "diff" + )); + 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, true), + Some(Subcommand::Command(cmd)) if cmd.name == "reset" + )); + assert!(matches!( + find_subcommand("reso", &aliases, true), + Some(Subcommand::Alias(alias)) if alias.name == "resolved" + )); + assert!(matches!( + find_subcommand("st", &aliases, true), + Some(Subcommand::Alias(alias)) if alias.name == "status" + )); + assert!(find_subcommand("h", &aliases, true).is_none()); + assert!(matches!( + find_subcommand("he", &aliases, true), + Some(Subcommand::Help) + )); + assert!(matches!( + find_subcommand("hi", &aliases, true), + 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, true).is_none()); + assert!(matches!( + find_subcommand("show", &aliases, true), + Some(Subcommand::Command(cmd)) if cmd.name == "show" + )); + assert!(matches!( + 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 e8b8afab..60cb8964 100755 --- a/t/t0004-main.sh +++ b/t/t0004-main.sh @@ -24,12 +24,73 @@ 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 && + 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' ' + test_config stgit.infer-commands true && + 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 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 && 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 && + 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 && + 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..c4581be1 100755 --- a/t/t0010-alias.sh +++ b/t/t0010-alias.sh @@ -12,10 +12,27 @@ 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 && + 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 + 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 +' + +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' ' diff --git a/t/t1901-email.sh b/t/t1901-email.sh index d4089151..523ea6d5 100755 --- a/t/t1901-email.sh +++ b/t/t1901-email.sh @@ -14,4 +14,13 @@ test_expect_success 'Check email subcommand help' ' 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 +' + test_done