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
12 changes: 12 additions & 0 deletions Documentation/stg.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
253 changes: 232 additions & 21 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subcommand<'a>> {
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
Expand Down Expand Up @@ -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)
Expand All @@ -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<OsString> = sub_matches
.get_many::<OsString>("")
.map_or_else(Vec::new, |vals| vals.cloned().collect());

match alias.kind {
alias::AliasKind::Shell => execute_shell_alias(
alias,
Expand All @@ -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
Expand All @@ -221,7 +287,7 @@ fn main() -> ! {
// expected to terminate with an appropriate help message.
full_app_help(argv, Some(aliases), color_choice)
}
}
},
}
}
} else {
Expand All @@ -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::<OsString>("")
.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
Expand Down Expand Up @@ -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<OsString>,
aliases: Option<alias::Aliases>,
Expand Down Expand Up @@ -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<OsString>,
color_choice: Option<termcolor::ColorChoice>,
infer_commands: Option<bool>,
) -> ! {
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) => {
Expand Down Expand Up @@ -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<OsString>,
color_choice: Option<termcolor::ColorChoice>,
aliases: &alias::Aliases,
infer_commands: bool,
) -> ! {
let result = match alias.split() {
Ok(alias_args) => {
Expand All @@ -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)),
Expand All @@ -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<gix::Repository>,
}

/// Get aliases mapping.
///
/// Since aliases are defined in git config files, an attempt is made to open a repo so
Expand All @@ -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<gix::Repository>)> {
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<DispatchConfig> {
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());
Expand All @@ -544,7 +669,17 @@ pub(crate) fn get_aliases() -> Result<(alias::Aliases, Option<gix::Repository>)>
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.
Expand Down Expand Up @@ -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"
));
}
}
Loading
Loading