Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
70 changes: 63 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::{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,20 @@ 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 that
/// aren't "not a repository" 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 +561,57 @@ 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
/// `err` says the failure is genuinely "not a repository", 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.
///
Comment thread
worktrunk-bot marked this conversation as resolved.
Outdated
/// Only "not a repository" earns the alias message; any other discovery
/// failure (a spawn error from a bad `-C` path, a git that errors for its
/// own reasons) propagates as itself, the way it does for every other
/// command — asserting "there's no git repository here" without the 128
/// would misreport those.
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 outside a repository — the structured
// "not a repository" channel, per "Structured Output Over
// Error-Message Parsing".
if !err
.downcast_ref::<worktrunk::git::CommandError>()
.is_some_and(|e| e.exit_code == Some(128))
{
return Err(err);
}
eprintln!(
"{}",
error_message(cformat!(
"<bold>{name}</> is an alias, but there's no git repository here"
))
);
// 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
131 changes: 129 additions & 2 deletions tests/integration_tests/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

use crate::common::{
mock_commands::{MockConfig, MockResponse},
wt_command,
setup_home_snapshot_settings, wt_command,
};
Comment thread
worktrunk-bot marked this conversation as resolved.
// Used only by the `#[cfg(unix)]` non-UTF-8 test below; gate the import to
// match, or it reads as unused on Windows under `-D warnings`.
#[cfg(unix)]
use crate::common::TestRepo;
use ansi_str::AnsiStr;
use insta_cmd::assert_cmd_snapshot;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
Expand Down Expand Up @@ -35,11 +40,21 @@ fn mock_bin_dir(name: &str, response: MockResponse) -> TempDir {
}

/// Limit PATH to a supported Git executable, excluding every `wt-*` custom
/// subcommand without bypassing wt's startup requirements.
/// subcommand without bypassing wt's startup requirements. `rev-parse`
/// answers the way real git does outside a repository — exit 128 with the
/// fatal message — so repo-discovery tests exercise "no repository" rather
/// than "git failed" (mock_stub's fallback for an unmatched command is
/// exit 1 with no output).
fn set_git_only_path(cmd: &mut Command) -> TempDir {
let dir = TempDir::new().unwrap();
MockConfig::new("git")
.version("git version 2.43.0")
.command(
"rev-parse",
MockResponse::exit(128).with_stderr(
"fatal: not a git repository (or any of the parent directories): .git\n",
),
)
.write(dir.path());
cmd.env("PATH", dir.path())
.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", dir.path());
Expand Down Expand Up @@ -197,6 +212,118 @@ fn custom_subcommand_typo_suggests_closest_builtin() {
);
}

#[test]
fn custom_subcommand_alias_outside_repo_names_the_alias() {
// `wt co` outside a git repository, with `co` a user-config alias: alias
// dispatch ends inside try_alias with the alias-needs-repository error —
// a configured alias isn't "unrecognized", and the clap-style fallback
// would suggest the typed name itself.
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("config.toml");
std::fs::write(&config_path, "[aliases]\nco = \"echo hi\"\n").unwrap();

let mut cmd = wt_command();
let _git_only_path = set_git_only_path(&mut cmd);
cmd.env("WORKTRUNK_CONFIG_PATH", &config_path);
cmd.arg("co");

let settings = setup_home_snapshot_settings(&config_dir);
let _guard = settings.bind_to_scope();

assert_cmd_snapshot!(cmd);
}

#[test]
fn custom_subcommand_alias_outside_repo_wins_over_path_binary() {
// Outside a repository the user-config alias still owns its name: dispatch
// ends in try_alias with the needs-repository error instead of falling
// through to a colliding `wt-<name>` PATH binary — the same "user config
// wins over wt-<name> PATH binaries" precedence as in-repo dispatch.
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("config.toml");
std::fs::write(
&config_path,
"[aliases]\nwt-test-extcmd-alias-shadow = \"echo hi\"\n",
)
.unwrap();
let bin_dir = mock_bin_dir(
"wt-wt-test-extcmd-alias-shadow",
MockResponse::output("path binary ran\n"),
);

let mut cmd = wt_command();
// No `set_git_only_path` here: `prepend_path` rebuilds PATH from this
// process's environment, so a git-only PATH set above it would be
// discarded. The unique alias name is what keeps host `wt-*` binaries
// out of the way.
prepend_path(&mut cmd, bin_dir.path());
Comment thread
worktrunk-bot marked this conversation as resolved.
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", bin_dir.path());
cmd.env("WORKTRUNK_CONFIG_PATH", &config_path);
cmd.arg("wt-test-extcmd-alias-shadow");

let output = cmd.output().expect("failed to run wt");
assert!(!output.status.success(), "expected failure");
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("wt-test-extcmd-alias-shadow is an alias"),
"error should name the configured alias: {stderr}"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("path binary ran"),
"the colliding PATH binary must not run: {stdout}"
);
}

#[cfg(unix)]
#[test]
fn custom_subcommand_alias_with_non_utf8_arg_never_self_suggests() {
// Non-UTF-8 args skip alias dispatch wholesale (the alias arg parser
// requires UTF-8), so inside a repo `wt co <raw byte>` with `co`
// configured still falls through to the clap-style error. The
// self-suggestion filter in similar_subcommands keeps that error's tip
// from suggesting 'co' — the name the user typed.
use std::ffi::OsString;
Comment thread
worktrunk-bot marked this conversation as resolved.
use std::os::unix::ffi::OsStringExt;

let repo = TestRepo::new();
repo.write_project_config(
r#"[aliases]
co = "echo hi"
"#,
);

let mut cmd = repo.wt_command();
cmd.arg("co").arg(OsString::from_vec(vec![0xff]));

let output = cmd.output().expect("failed to run wt");
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("unrecognized subcommand 'co'"),
"dispatch falls through to the clap-style error: {stderr}"
);
// The error line legitimately quotes 'co'; only the tip must not.
let tip = stderr
.lines()
.find(|line| line.contains("tip:"))
.unwrap_or_default();
assert!(
tip.contains("'config'"),
"tip should suggest the built-in it is similar to: {stderr}"
);
assert!(
!tip.contains("'co'"),
"the tip must not suggest the typed name itself: {tip}"
);
}

#[test]
fn custom_subcommand_nested_suggestion_wins_over_path_lookup() {
// `wt squash` should suggest `wt step squash` even though `wt-squash` is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
source: tests/integration_tests/custom.rs
info:
program: wt
args:
- co
env:
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_ALLOW_PROTOCOL: file
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig
GIT_CONFIG_KEY_0: user.useConfigOnly
GIT_CONFIG_KEY_1: rerere.enabled
GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig
GIT_CONFIG_VALUE_0: "true"
GIT_CONFIG_VALUE_1: "false"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
PATH: "[PATH]"
TERM: alacritty
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
---
success: false
exit_code: 1
----- stdout -----

----- stderr -----
✗ co is an alias, but there's no git repository here
↳ Run wt inside a repository, or to target one, run wt -C <path> co
Loading