Skip to content
Open
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
78 changes: 71 additions & 7 deletions src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ use worktrunk::config::{
ALIAS_ARGS_KEY, CommandConfig, ProjectConfig, UserConfig, VarScope, alias_context_filter,
format_alias_variables, referenced_vars_for_config,
};
use worktrunk::git::Repository;
use worktrunk::git::{CommandError, Repository, WorktrunkError};
use worktrunk::styling::{
eprintln, format_with_gutter, hint_message, info_message, progress_message, verbosity,
warning_message,
eprintln, error_message, format_with_gutter, hint_message, info_message, progress_message,
verbosity, warning_message,
};
use worktrunk::trace::Span;

Expand Down Expand Up @@ -433,15 +433,21 @@ fn referenced_vars_for_entry(name: &str, entry: &AliasEntry) -> anyhow::Result<B
/// `global_yes` is the top-level `--yes`/`-y` flag, passed through to
/// `run_alias`.
///
/// Alias execution needs a git repository; without one this returns `Ok(None)`
/// so the caller falls through to PATH lookup. Config load errors propagate —
/// Alias execution needs a git repository. Without one this returns `Ok(None)`
/// for names that aren't user-config aliases, so the caller falls through to
/// PATH lookup; a name that IS a user-config alias ends here with the
/// alias-needs-repository error (see [`alias_needs_repo_error`]) — dispatch
/// never reaches the `wt-<name>` PATH lookup for it. Discovery failures git
/// didn't answer for (a spawn error) propagate, and config load errors
/// propagate —
/// a broken `wt.toml` should fail loudly here just as it does for `wt list`,
/// rather than silently turning into an "unrecognized subcommand" once we
/// fall through to PATH lookup.
pub fn try_alias(name: String, rest: Vec<String>, global_yes: bool) -> anyhow::Result<Option<()>> {
let _span = Span::new(format!("try_alias:{}", name));
let Ok(repo) = Repository::current() else {
return Ok(None);
let repo = match Repository::current() {
Ok(repo) => repo,
Err(err) => return alias_needs_repo_error(&name, err),
};
let user_config = repo.user_config();
let project_config = repo.project_config()?;
Expand Down Expand Up @@ -556,6 +562,64 @@ pub fn alias_names_for_suggestions() -> Vec<String> {
.collect()
}

/// Outside a git repository an alias cannot run (execution resolves the
/// current worktree), yet `wt --help` still lists user-config aliases and
/// the typo suggestions still include them. When `name` is one of those and
/// repository discovery died in git, print the alias-needs-repository error
/// and return it — a configured alias isn't "unrecognized", and the caller's
/// clap-style fallback would suggest the very name the user typed as its own
/// "did you mean". Otherwise `Ok(None)`: not an alias here, the caller falls
/// through to the `wt-<name>` PATH lookup — whatever git did. Best-effort —
/// a config that fails to load reads as no aliases.
///
/// A 128 exit is the closest structured signal git offers, not a dedicated
/// "not a repository" code: `git rev-parse` dies with 128 for any fatal
/// error, so a `safe.directory` violation or an unsupported
/// `core.repositoryformatversion` reaches this message too, inside a real
/// repository. That is why the message states what aliases require and
/// quotes git's own line for what went wrong, instead of asserting a cause
/// the exit code doesn't carry. Failures that exit some other way (a spawn
/// error from a bad `-C` path) propagate as themselves, the way they do for
/// every other command.
fn alias_needs_repo_error(name: &str, err: anyhow::Error) -> anyhow::Result<Option<()>> {
worktrunk::config::suppress_warnings();
let is_alias = UserConfig::load()
.map(|uc| uc.aliases(None).contains_key(name))
.unwrap_or(false);
if !is_alias {
Comment thread
worktrunk-bot marked this conversation as resolved.
return Ok(None);
}
// `git rev-parse` exits 128 when discovery fails fatally — the structured
// channel, per "Structured Output Over Error-Message Parsing", though not
// one specific to "not a repository"; see the docstring.
let Some(git_err) = CommandError::find_in(&err).filter(|e| e.exit_code == Some(128)) else {
return Err(err);
};
eprintln!(
"{}",
error_message(cformat!(
"<bold>{name}</> is an alias, but aliases only run inside a git repository"
))
);
// git's own line says which fatal error this was — "not a git repository"
// in the common case, dubious ownership or an unreadable config in the
// others, where the alias message alone would misreport the cause.
let git_output = git_err.combined_output();
if !git_output.is_empty() {
eprintln!("{}", format_with_gutter(&git_output, None));
}
// Built with `format!` so `-C <path>` stays out of the `cformat!`
// literal, which is what color-print's tag parser reads.
let targeted = format!("wt -C <path> {name}");
eprintln!(
"{}",
hint_message(cformat!(
"Run wt inside a repository, or to target one, run <underline>{targeted}</>"
))
);
Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into())
}

/// Execute the alias body for `opts.name`. Caller must have already verified
/// the entry exists.
///
Expand Down
21 changes: 17 additions & 4 deletions src/commands/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
//! 1. **Alias**: if `foo` is configured as an alias in user/project config,
//! run it via the same path as `wt step foo`. User config wins over
//! `wt-<name>` PATH binaries — aliases are how users customize wt, so the
//! user's intent should take precedence.
//! user's intent should take precedence. Outside a repository an alias
//! cannot run (execution resolves the current worktree), so dispatch ends
//! there with the alias-needs-repository error and the PATH binary never
//! sees the name.
//! 2. **PATH binary**: resolve `wt-<name>` via `which`. If found, run it with
//! the remaining args, inheriting stdio, and propagate the exit code.
//! Mirrors how `git foo` finds `git-foo`.
Expand Down Expand Up @@ -220,12 +223,22 @@ mod tests {
}

#[test]
fn similar_subcommands_dedupes_alias_matching_builtin() {
// An alias whose name shadows a built-in (e.g. `list`) should appear
// only once in suggestions, not duplicated.
fn similar_subcommands_never_suggests_the_input_itself() {
// An exact match — e.g. a user-config alias named like the input — is
// never a "similar" suggestion: dispatch paths that reach the error
// with the input in the candidate pool (alias outside a repository,
// alias skipped as non-UTF-8) must not tip the typed name back.
let cmd = build_command();
let aliases = vec!["list".to_string()];
let suggestions = similar_subcommands("list", &cmd, &aliases);
assert!(
!suggestions.contains(&"list".to_string()),
"got: {suggestions:?}"
);

// A near match reachable from both pools (built-in + alias of the same
// name) still appears once — the dedupe set survives the filter.
let suggestions = similar_subcommands("lst", &cmd, &aliases);
let count = suggestions.iter().filter(|n| *n == "list").count();
assert_eq!(count, 1, "got: {suggestions:?}");
}
Expand Down
17 changes: 13 additions & 4 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,13 @@ pub(crate) fn did_you_mean(

/// Return visible subcommand names of `parent` plus `alias_names`, filtered to
/// those similar to `name`. Hidden subcommands (e.g., deprecated aliases) and
/// clap's implicit `help` are excluded. Shared by the top-level (`wt <typo>`)
/// and `wt step <typo>` suggestion paths — both surfaces want "visible
/// built-ins + configured aliases" as the candidate pool.
/// clap's implicit `help` are excluded, and `name` itself never appears: some
/// dispatch paths reach this error with the input present in the candidate
/// pool (a user-config alias outside a repository, or one whose args skipped
/// alias dispatch as non-UTF-8), and "did you mean the thing you typed" is
/// never a useful tip. Shared by the top-level (`wt <typo>`) and
/// `wt step <typo>` suggestion paths — both surfaces want "visible built-ins +
/// configured aliases" as the candidate pool.
pub(crate) fn similar_subcommands(
name: &str,
parent: &clap::Command,
Expand All @@ -130,7 +134,12 @@ pub(crate) fn similar_subcommands(
.filter(|c| !c.is_hide_set())
.map(|c| c.get_name().to_string())
.filter(|candidate| candidate != "help");
did_you_mean(name, builtins.chain(alias_names.iter().cloned()))
did_you_mean(
name,
builtins
.chain(alias_names.iter().cloned())
.filter(|candidate| candidate != name),
)
}

/// Build a clap `InvalidSubcommand` error anchored on `anchor`, populating the
Expand Down
Loading
Loading