mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
96c6c846f7
## Problem
`wt config shell init <shell> --cmd <name>` renames the shell wrapper
and its lazy completion loader, but the registration that loader evals
comes from clap, which derives every identifier in it from its own
compile-time `Command` name (`wt`) — not from `argv[0]` and not from
`--cmd`. The two halves never agreed:
```console
$ wt config shell init zsh --cmd wot | grep _clap
if ! (( $+functions[_clap_dynamic_completer_wot] )); then
_clap_dynamic_completer_wot "$@"
$ COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*' | sort -u
_clap_dynamic_completer_wt
```
Nothing completed, and because the guard never became true the
completion script was regenerated and re-evaluated on *every* TAB. Same
shape in bash (`_clap_complete_*`); PowerShell emitted
`Register-ArgumentCompleter -Native -CommandName wt`, so the `--cmd`
name was never registered at all. The documented `--cmd=git-wt` case
(the Windows Terminal conflict) was broken too — including for a binary
genuinely installed under that name, since clap's name comes from the
declaration rather than `argv[0]`.
There is a second, sharper edge: zsh's registration ends with `compdef
<completer> <cmd>`, so the first TAB on `wot` also bound worktrunk's
completer to plain `wt` — handing completions to the *other* `wt` that
`--cmd` exists to step around.
fish and nushell were unaffected. Both register a completer that shells
out to the binary rather than depending on a clap-emitted identifier, so
the reporter's "unverified" row for fish is a pass.
## Solution
The bash, zsh, and PowerShell loaders now pass the name they bind in
`WORKTRUNK_COMPLETE_NAME`, and `registration_name()` in
`src/completion.rs` emits the registration under that name (validated
through the same `validate_shell_command_name` guard `--cmd` uses, since
the value lands verbatim in generated shell code). The fallback is
`binary_name()`, which covers a binary installed as `git-wt` and invoked
directly. The templates apply clap's own `-` → `_` escaping to the
function they call, so `--cmd git-wt` guards on `_clap_complete_git_wt`
rather than the invalid `_clap_complete_git-wt`.
That fixes all four shells and the stray `compdef` in one place, rather
than pinning the templates to clap's internal naming:
```console
$ WORKTRUNK_COMPLETE_NAME=wot COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*|compdef .*' | sort -u
_clap_dynamic_completer_wot
compdef _clap_dynamic_completer_wot wot
```
## Testing
Two reproduction tests in `tests/integration_tests/completion.rs`, both
failing before the change:
- `test_init_custom_cmd_defines_clap_completer_in_bash` drives the whole
chain through a real bash — generate the init script with `--cmd`, call
the loader it defines, then assert clap's completer function exists
afterwards. Printed `MISSING` before, `DEFINED` after. Cases for `wot`
and `git-wt`.
- `test_completion_registration_uses_shell_integration_cmd_name` covers
zsh and PowerShell, which CI can't drive: the identifier the init script
references must be the one the registration defines, and the `compdef` /
`-CommandName` target must be the `--cmd` name.
`cargo test --lib --bins` and `cargo test --test integration` are
otherwise green (one unrelated failure locally,
`test_copy_ignored_preserves_file_executable_permissions`, from this
sandbox's `umask 0002`), and `cargo clippy --all-targets --all-features`
/ `cargo fmt --check` are clean.
---
Closes #3816 — automated triage
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2397 lines
85 KiB
Rust
2397 lines
85 KiB
Rust
use crate::common::{
|
|
TestRepo,
|
|
mock_commands::{MockConfig, MockResponse},
|
|
repo, wt_command, wt_completion_command,
|
|
};
|
|
use insta::Settings;
|
|
use rstest::rstest;
|
|
|
|
fn only_option_suggestions(stdout: &str) -> bool {
|
|
stdout
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.all(|line| line.starts_with('-'))
|
|
}
|
|
|
|
fn has_any_options(stdout: &str) -> bool {
|
|
stdout.lines().any(|line| line.trim().starts_with('-'))
|
|
}
|
|
|
|
fn value_suggestions(stdout: &str) -> Vec<&str> {
|
|
stdout
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| {
|
|
if line.is_empty() {
|
|
false
|
|
} else if line.starts_with('-') {
|
|
line.contains('=')
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_shows_branches(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create some branches using git
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Test completion for switch command
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains("feature/new"));
|
|
assert!(stdout.contains("hotfix/bug"));
|
|
assert!(stdout.contains("main"));
|
|
});
|
|
}
|
|
|
|
/// `WORKTRUNK_VERBOSE` reaches the completion subprocess, which exits before
|
|
/// `main`'s `logging::init` and is otherwise silent. It is the env-var
|
|
/// equivalent of `-v`/`-vv`, so at level 2 completion writes the same
|
|
/// humanized timing (`✓ git …`) and `$ git …` start records to
|
|
/// `.git/wt/logs/trace.log` that `-vv` produces — the only way to profile a
|
|
/// slow tab-completion, since the shell invokes completion with nowhere to
|
|
/// pass `-vv`. Unset, completion writes nothing and keeps the candidate output
|
|
/// clean.
|
|
#[rstest]
|
|
fn test_completion_honors_worktrunk_verbose(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
let logs_dir = repo.root_path().join(".git").join("wt/logs");
|
|
let trace_log = logs_dir.join("trace.log");
|
|
|
|
// Control: a normal completion logs nothing and still emits candidates.
|
|
let _ = std::fs::remove_dir_all(&logs_dir);
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", ""])
|
|
.env_remove("WORKTRUNK_VERBOSE")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
assert!(
|
|
String::from_utf8_lossy(&output.stdout).contains("feature/new"),
|
|
"completion should produce candidates"
|
|
);
|
|
assert!(
|
|
!trace_log.exists(),
|
|
"completion must not log without WORKTRUNK_VERBOSE"
|
|
);
|
|
|
|
// WORKTRUNK_VERBOSE=2 == -vv: completion writes the trace files, without
|
|
// disturbing the candidate list.
|
|
let _ = std::fs::remove_dir_all(&logs_dir);
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", ""])
|
|
.env("WORKTRUNK_VERBOSE", "2")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
assert!(
|
|
String::from_utf8_lossy(&output.stdout).contains("feature/new"),
|
|
"logging must not disturb the candidate list"
|
|
);
|
|
let trace = std::fs::read_to_string(&trace_log)
|
|
.expect("WORKTRUNK_VERBOSE=2 should write trace.log during completion");
|
|
assert!(
|
|
trace.contains("✓ git"),
|
|
"completion trace should capture humanized command timing: {trace}"
|
|
);
|
|
assert!(
|
|
trace.contains("$ git"),
|
|
"completion trace should capture the git subprocesses it runs: {trace}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_shows_all_branches_including_worktrees(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create worktree (this creates a new branch "feature/new")
|
|
repo.add_worktree("feature/new");
|
|
|
|
// Create another branch without worktree
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Test completion - should show branches WITH worktrees and WITHOUT worktrees
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains("feature/new"));
|
|
assert!(stdout.contains("hotfix/bug"));
|
|
assert!(stdout.contains("main"));
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_push_shows_all_branches(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create worktree (creates "feature/new" branch)
|
|
repo.add_worktree("feature/new");
|
|
|
|
// Create another branch without worktree
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Test completion for step push (should show ALL branches, including those with worktrees)
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "step", "push", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.contains(&"feature/new"),
|
|
"values should list feature/new\n{stdout}"
|
|
);
|
|
assert!(values.contains(&"hotfix/bug"));
|
|
assert!(values.contains(&"main"));
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_base_flag_all_formats(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches
|
|
repo.run_git(&["branch", "develop"]);
|
|
repo.run_git(&["branch", "feature/existing"]);
|
|
|
|
// Test all base flag formats: --base, -b, --base=, -b=
|
|
// For space-separated (--base ""), cursor is on empty arg after flag
|
|
// For equals (--base=), cursor is completing the value after equals
|
|
let test_cases: &[&[&str]] = &[
|
|
&["wt", "switch", "--create", "new-branch", "--base", ""], // long form with space
|
|
&["wt", "switch", "--create", "new-branch", "-b", ""], // short form with space
|
|
&["wt", "switch", "--create", "new-branch", "--base="], // long form with equals
|
|
&["wt", "switch", "--create", "new-branch", "-b="], // short form with equals
|
|
];
|
|
|
|
for args in test_cases {
|
|
let output = repo.completion_cmd(args).output().unwrap();
|
|
assert!(output.status.success(), "Failed for args: {:?}", args);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("develop")),
|
|
"Missing develop for {:?}: {:?}",
|
|
args,
|
|
branches
|
|
);
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("feature/existing")),
|
|
"Missing feature/existing for {:?}: {:?}",
|
|
args,
|
|
branches
|
|
);
|
|
}
|
|
|
|
// Test partial completion --base=m (clap returns "--base=<value>" form,
|
|
// so bash prefix filter matches correctly: "--base=main".starts_with("--base=m"))
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--create", "new-branch", "--base=m"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches = value_suggestions(&stdout);
|
|
assert!(branches.iter().any(|b| b.contains("main")));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_outside_git_repo() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
|
|
settings.bind(|| {
|
|
let output = wt_completion_command(&["wt", "switch", ""])
|
|
.current_dir(temp.path())
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.all(|line| line.starts_with('-')),
|
|
"expected only option suggestions outside git repo, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_empty_repo() {
|
|
// Regression for #3094: in a fresh `git init`'d repo, the unborn
|
|
// default branch exists only as a `symbolic-ref` target, not under
|
|
// `refs/heads/`. `branches_for_completion()` falls back to
|
|
// `git worktree list --porcelain` so the primary worktree's branch
|
|
// still surfaces.
|
|
let repo = TestRepo::empty();
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
|
|
settings.bind(|| {
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.contains(&"main"),
|
|
"expected `main` to appear as a completion candidate in an empty repo, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_unknown_command(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "unknown-command", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let suggestions = value_suggestions(&stdout);
|
|
assert!(
|
|
suggestions.contains(&"config"),
|
|
"should fall back to root completions, got:\n{stdout}"
|
|
);
|
|
assert!(suggestions.contains(&"list"));
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_commit_no_positionals(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "step", "commit", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.all(|line| line.starts_with('-')),
|
|
"step commit should only suggest flags, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_list_command(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
|
|
settings.bind(|| {
|
|
let output = repo.completion_cmd(&["wt", "list", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
// wt list suggests flags (--format, --branches, etc.) and subcommands (statusline)
|
|
assert!(
|
|
stdout
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.all(|line| line.starts_with('-') || line == "statusline"),
|
|
"wt list should only suggest flags or 'statusline' subcommand, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_init_fish_no_inline_completions() {
|
|
// Test that fish init does NOT have inline completions (they're in a separate file)
|
|
let mut cmd = wt_command();
|
|
let output = cmd
|
|
.arg("config")
|
|
.arg("shell")
|
|
.arg("init")
|
|
.arg("fish")
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Verify completions are NOT inline - they go to ~/.config/fish/completions/wt.fish
|
|
assert!(
|
|
!stdout.contains("complete --keep-order --exclusive --command wt --arguments"),
|
|
"Fish init should NOT have inline completions (they go to separate file)"
|
|
);
|
|
// But should reference where completions are
|
|
assert!(
|
|
stdout.contains("Completions are in"),
|
|
"Fish init should mention where completions are"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_with_partial_prefix_returns_all_branches_in_fish(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches with common prefix
|
|
repo.run_git(&["branch", "feature/one"]);
|
|
repo.run_git(&["branch", "feature/two"]);
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Fish/zsh apply their own matching (substring, fuzzy), so the binary returns
|
|
// ALL candidates. This enables fish/zsh substring matching.
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "feat"], "fish")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
// All branches returned for fish (no prefix filtering)
|
|
assert!(values.iter().any(|v| v.contains("feature/one")));
|
|
assert!(values.iter().any(|v| v.contains("feature/two")));
|
|
assert!(values.iter().any(|v| v.contains("hotfix/bug")));
|
|
assert!(values.iter().any(|v| v.contains("main")));
|
|
}
|
|
|
|
/// Typing a substring that appears mid-branch (e.g. "auth") should still return
|
|
/// branches containing that substring, because the binary no longer prefix-filters.
|
|
/// Fish/zsh apply their own matching (substring, fuzzy), so the binary returns
|
|
/// all candidates for those shells. This is the core use case from #1468:
|
|
/// `wt switch auth<TAB>` should let fish/zsh match `feature/user-auth`.
|
|
#[rstest]
|
|
fn test_complete_switch_returns_candidates_for_substring_matching(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature/user-auth"]);
|
|
repo.run_git(&["branch", "bugfix/user-auth-timeout"]);
|
|
repo.run_git(&["branch", "release/2024-q1"]);
|
|
|
|
// Type "auth" in fish — not a prefix of any branch, but fish does substring matching
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "auth"], "fish")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
values.iter().any(|v| v.contains("feature/user-auth")),
|
|
"should return feature/user-auth for shell substring matching\n{stdout}"
|
|
);
|
|
assert!(
|
|
values
|
|
.iter()
|
|
.any(|v| v.contains("bugfix/user-auth-timeout")),
|
|
"should return bugfix/user-auth-timeout for shell substring matching\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("release/2024-q1")),
|
|
"should return all branches regardless of typed prefix\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// Bash does not filter COMPREPLY by prefix — the binary must return only
|
|
/// prefix-matching candidates. (#1621)
|
|
#[rstest]
|
|
fn test_complete_switch_bash_filters_by_prefix(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature/user-auth"]);
|
|
repo.run_git(&["branch", "feature/login"]);
|
|
repo.run_git(&["branch", "bugfix/crash"]);
|
|
repo.run_git(&["branch", "release/2024-q1"]);
|
|
|
|
// Type "feat" in bash — should only return branches starting with "feat"
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "feat"], "bash")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
values.iter().any(|v| v.contains("feature/user-auth")),
|
|
"should include feature/user-auth (prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("feature/login")),
|
|
"should include feature/login (prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.iter().any(|v| v.contains("bugfix/crash")),
|
|
"should NOT include bugfix/crash (not a prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.iter().any(|v| v.contains("release/2024-q1")),
|
|
"should NOT include release/2024-q1 (not a prefix match)\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// Cross-shell completion contract: each shell gets the filtering it needs.
|
|
///
|
|
/// This captures the tension between #1468 (fish/zsh need all candidates for
|
|
/// substring/fuzzy matching) and #1621 (bash needs prefix filtering because
|
|
/// its programmable completion doesn't filter COMPREPLY).
|
|
///
|
|
/// The same set of branches with the same typed prefix must produce different
|
|
/// results depending on the shell:
|
|
/// - bash: only prefix matches (binary filters)
|
|
/// - fish: all candidates (fish does substring matching)
|
|
/// - zsh: all candidates (zsh does fuzzy matching)
|
|
#[rstest]
|
|
fn test_completion_cross_shell_filtering_contract(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature/user-auth"]);
|
|
repo.run_git(&["branch", "bugfix/auth-timeout"]);
|
|
repo.run_git(&["branch", "release/2024-q1"]);
|
|
|
|
// Prefix "feat" — matches feature/* but not bugfix/* or release/*
|
|
for shell in ["fish", "zsh"] {
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "feat"], shell)
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("bugfix/auth-timeout")),
|
|
"{shell} should return ALL candidates (shell does its own matching)\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("release/2024-q1")),
|
|
"{shell} should return ALL candidates\n{stdout}"
|
|
);
|
|
}
|
|
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "feat"], "bash")
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("feature/user-auth")),
|
|
"bash should return prefix matches\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.iter().any(|v| v.contains("bugfix/auth-timeout")),
|
|
"bash should NOT return non-prefix matches\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.iter().any(|v| v.contains("release/2024-q1")),
|
|
"bash should NOT return non-prefix matches\n{stdout}"
|
|
);
|
|
|
|
// Substring "auth" — appears mid-branch, not as a prefix
|
|
for shell in ["fish", "zsh"] {
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "auth"], shell)
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.iter().any(|v| v.contains("feature/user-auth")),
|
|
"{shell} should return all candidates so shell can substring-match 'auth'\n{stdout}"
|
|
);
|
|
}
|
|
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", "auth"], "bash")
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
!values.iter().any(|v| v.contains("feature/user-auth")),
|
|
"bash should not return 'feature/user-auth' — 'auth' is not a prefix\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// Bash with empty prefix should still return all branches.
|
|
#[rstest]
|
|
fn test_complete_switch_bash_empty_prefix_shows_all(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
repo.run_git(&["branch", "bugfix/crash"]);
|
|
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", ""], "bash")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(stdout.contains("feature/new"));
|
|
assert!(stdout.contains("bugfix/crash"));
|
|
assert!(stdout.contains("main"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_shows_all_branches_even_with_worktrees(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create two branches, both with worktrees
|
|
repo.add_worktree("feature/new");
|
|
repo.add_worktree("hotfix/bug");
|
|
|
|
// From the main worktree, test completion - should show all branches
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should include branches even if they have worktrees (can switch to them)
|
|
assert!(stdout.contains("feature/new"));
|
|
assert!(stdout.contains("hotfix/bug"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_excludes_remote_branches(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create local branches
|
|
repo.run_git(&["branch", "feature/local"]);
|
|
|
|
// Create a new bare repo to act as remote (fixture already has origin remote)
|
|
let remote_dir = repo.root_path().parent().unwrap().join("remote.git");
|
|
repo.git_command()
|
|
.args(["init", "--bare", "-b", "main", remote_dir.to_str().unwrap()])
|
|
.run()
|
|
.unwrap();
|
|
|
|
// Update origin URL to point to our bare repo
|
|
repo.run_git(&["remote", "set-url", "origin", remote_dir.to_str().unwrap()]);
|
|
|
|
// Push to create remote branches
|
|
repo.run_git(&["push", "origin", "main"]);
|
|
repo.run_git(&["push", "origin", "feature/local:feature/remote"]);
|
|
|
|
// Fetch to create remote-tracking branches
|
|
repo.run_git(&["fetch", "origin"]);
|
|
|
|
// Test completion
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should include local branch without worktree
|
|
assert!(
|
|
stdout.contains("feature/local"),
|
|
"Should include feature/local branch, but got: {}",
|
|
stdout
|
|
);
|
|
|
|
// main branch has a worktree (the root repo), so it may or may not be included
|
|
// depending on switch context - not critical for this test
|
|
|
|
// Should NOT include remote-tracking branches (origin/*)
|
|
assert!(
|
|
!stdout.contains("origin/"),
|
|
"Completion should not include remote-tracking branches, but found: {}",
|
|
stdout
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_merge_shows_branches(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create worktree (creates "feature/new" branch)
|
|
repo.add_worktree("feature/new");
|
|
|
|
// Create another branch without worktree
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Test completion for merge (should show ALL branches, including those with worktrees)
|
|
let output = repo.completion_cmd(&["wt", "merge", ""]).output().unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches: Vec<&str> = stdout.lines().collect();
|
|
|
|
// Should include both branches (merge shows all)
|
|
assert!(branches.iter().any(|b| b.contains("feature/new")));
|
|
assert!(branches.iter().any(|b| b.contains("hotfix/bug")));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_with_special_characters_in_branch_names(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches with various special characters
|
|
let branch_names = vec![
|
|
"feature/FOO-123", // Uppercase + dash + numbers
|
|
"release/v1.2.3", // Dots
|
|
"hotfix/bug_fix", // Underscore
|
|
"feature/multi-part-name", // Multiple dashes
|
|
];
|
|
|
|
for branch in &branch_names {
|
|
repo.run_git(&["branch", branch]);
|
|
}
|
|
|
|
// Test completion
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
// All branches should be present
|
|
for branch in &branch_names {
|
|
assert!(
|
|
values.contains(branch),
|
|
"Branch {} should be in completion output",
|
|
branch
|
|
);
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_stops_after_branch_provided(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches
|
|
repo.run_git(&["branch", "feature/one"]);
|
|
repo.run_git(&["branch", "feature/two"]);
|
|
|
|
// Test that switch stops completing after branch is provided
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "feature/one", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"expected only option suggestions after positional provided, got:\n{stdout}"
|
|
);
|
|
});
|
|
|
|
// Test that step push stops completing after branch is provided
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "step", "push", "feature/one", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"expected only option suggestions after positional provided, got:\n{stdout}"
|
|
);
|
|
});
|
|
|
|
// Test that merge stops completing after branch is provided
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "merge", "feature/one", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"expected only option suggestions after positional provided, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_with_create_flag_no_completion(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature/existing"]);
|
|
|
|
// Test with --create flag (long form)
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--create", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"should not suggest branches when --create is present, got:\n{stdout}"
|
|
);
|
|
});
|
|
|
|
// Test with -c flag (short form)
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
settings.bind(|| {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "-c", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"should not suggest branches when -c is present, got:\n{stdout}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_base_flag_after_branch(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches
|
|
repo.run_git(&["branch", "develop"]);
|
|
|
|
// Test completion for --base even after --create and branch name
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--create", "new-feature", "--base", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should complete base flag value with branches
|
|
assert!(stdout.contains("develop"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_remove_excludes_remote_only_branches(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create worktree (creates "feature/new" branch)
|
|
repo.add_worktree("feature/new");
|
|
|
|
// Create another local branch without worktree
|
|
repo.run_git(&["branch", "hotfix/bug"]);
|
|
|
|
// Test completion for remove (should show local branches, exclude remote-only)
|
|
let output = repo.completion_cmd(&["wt", "remove", ""]).output().unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches: Vec<&str> = stdout.lines().collect();
|
|
|
|
// Should include branches with worktrees
|
|
assert!(branches.iter().any(|b| b.contains("feature/new")));
|
|
// Should include local branches without worktrees (can still delete the branch)
|
|
assert!(branches.iter().any(|b| b.contains("hotfix/bug")));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_subcommands(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Test: No input - shows all step subcommands (git operations only)
|
|
let output = repo.completion_cmd(&["wt", "step", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
// Git operations
|
|
assert!(subcommands.contains(&"commit"), "Missing commit");
|
|
assert!(subcommands.contains(&"squash"), "Missing squash");
|
|
assert!(subcommands.contains(&"push"), "Missing push");
|
|
assert!(subcommands.contains(&"rebase"), "Missing rebase");
|
|
assert!(
|
|
subcommands.contains(&"copy-ignored"),
|
|
"Missing copy-ignored"
|
|
);
|
|
assert!(subcommands.contains(&"diff"), "Missing diff");
|
|
assert!(subcommands.contains(&"eval"), "Missing eval");
|
|
assert!(subcommands.contains(&"for-each"), "Missing for-each");
|
|
assert!(subcommands.contains(&"promote"), "Missing promote");
|
|
assert!(subcommands.contains(&"prune"), "Missing prune");
|
|
assert!(subcommands.contains(&"relocate"), "Missing relocate");
|
|
assert!(subcommands.contains(&"tether"), "Missing tether");
|
|
assert_eq!(
|
|
subcommands.len(),
|
|
12,
|
|
"Should have exactly 12 step subcommands"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_hook_subcommands(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Test 1: No input - shows all hook subcommands
|
|
let output = repo.completion_cmd(&["wt", "hook", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
// Hook types and commands
|
|
assert!(subcommands.contains(&"show"), "Missing show");
|
|
assert!(subcommands.contains(&"pre-start"), "Missing pre-start");
|
|
assert!(subcommands.contains(&"post-start"), "Missing post-start");
|
|
assert!(subcommands.contains(&"post-switch"), "Missing post-switch");
|
|
assert!(subcommands.contains(&"pre-switch"), "Missing pre-switch");
|
|
assert!(subcommands.contains(&"pre-commit"), "Missing pre-commit");
|
|
assert!(subcommands.contains(&"post-commit"), "Missing post-commit");
|
|
assert!(subcommands.contains(&"pre-merge"), "Missing pre-merge");
|
|
assert!(subcommands.contains(&"post-merge"), "Missing post-merge");
|
|
assert!(subcommands.contains(&"pre-remove"), "Missing pre-remove");
|
|
assert!(subcommands.contains(&"post-remove"), "Missing post-remove");
|
|
assert_eq!(
|
|
subcommands.len(),
|
|
11,
|
|
"Should have exactly 11 hook subcommands"
|
|
);
|
|
|
|
// Test 2: Partial input "po" - filters to post-* subcommands
|
|
let output = repo.completion_cmd(&["wt", "hook", "po"]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
assert!(subcommands.contains(&"post-start"));
|
|
assert!(subcommands.contains(&"post-switch"));
|
|
assert!(subcommands.contains(&"post-commit"));
|
|
assert!(subcommands.contains(&"post-merge"));
|
|
assert!(subcommands.contains(&"post-remove"));
|
|
assert!(!subcommands.contains(&"pre-commit"));
|
|
assert!(!subcommands.contains(&"pre-merge"));
|
|
}
|
|
|
|
/// Cross-shell completion contract for hook command names.
|
|
///
|
|
/// Same contract as branch completions (test_completion_cross_shell_filtering_contract):
|
|
/// - fish/zsh get ALL candidates (shell does its own substring/fuzzy matching)
|
|
/// - bash gets only prefix-filtered candidates (binary must filter)
|
|
#[rstest]
|
|
fn test_hook_command_completion_cross_shell_filtering_contract(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Set up a project config with named pre-merge commands
|
|
repo.write_project_config(
|
|
r#"
|
|
pre-merge = [
|
|
{test = "cargo test"},
|
|
{lint = "cargo clippy"},
|
|
{build = "cargo build"},
|
|
]
|
|
"#,
|
|
);
|
|
|
|
// Prefix "te" — matches "test" but not "lint" or "build"
|
|
for shell in ["fish", "zsh"] {
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "hook", "pre-merge", "te"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
values.contains(&"test"),
|
|
"{shell} should return 'test' (prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.contains(&"lint"),
|
|
"{shell} should return ALL candidates (shell does its own matching)\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.contains(&"build"),
|
|
"{shell} should return ALL candidates\n{stdout}"
|
|
);
|
|
}
|
|
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "hook", "pre-merge", "te"], "bash")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
values.contains(&"test"),
|
|
"bash should return 'test' (prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.contains(&"lint"),
|
|
"bash should NOT return 'lint' (not a prefix match)\n{stdout}"
|
|
);
|
|
assert!(
|
|
!values.contains(&"build"),
|
|
"bash should NOT return 'build' (not a prefix match)\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_init_shell_all_variations(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Test 1: No input - shows all supported shells
|
|
let output = repo
|
|
.completion_cmd(&["wt", "config", "shell", "init", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let shells = value_suggestions(&stdout);
|
|
assert!(shells.contains(&"bash"));
|
|
assert!(shells.contains(&"fish"));
|
|
assert!(shells.contains(&"zsh"));
|
|
assert!(shells.contains(&"nu"));
|
|
assert!(!shells.contains(&"elvish"));
|
|
assert!(!shells.contains(&"nushell")); // clap name is "nu", not "nushell"
|
|
|
|
// Test 2: Partial input "fi" - filters to fish
|
|
let output = repo
|
|
.completion_cmd(&["wt", "config", "shell", "init", "fi"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let shells = value_suggestions(&stdout);
|
|
assert!(shells.contains(&"fish"));
|
|
assert!(!shells.contains(&"bash"));
|
|
|
|
// Test 3: Partial input "z" - filters to zsh
|
|
let output = repo
|
|
.completion_cmd(&["wt", "config", "shell", "init", "z"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let shells = value_suggestions(&stdout);
|
|
assert!(shells.contains(&"zsh"));
|
|
assert!(!shells.contains(&"bash"));
|
|
assert!(!shells.contains(&"fish"));
|
|
|
|
// Test 4: With --source flag - same behavior
|
|
let output = repo
|
|
.completion_cmd(&["wt", "--source", "config", "shell", "init", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let shells = value_suggestions(&stdout);
|
|
assert!(shells.contains(&"bash"));
|
|
assert!(shells.contains(&"fish"));
|
|
assert!(shells.contains(&"zsh"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_list_format_flag(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Test completion for list --format flag
|
|
let output = repo
|
|
.completion_cmd(&["wt", "list", "--format", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Each line is "name\tdescription" (fish format)
|
|
// Just check that both format names appear
|
|
let values = value_suggestions(&stdout);
|
|
assert!(values.contains(&"table"));
|
|
assert!(values.contains(&"json"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_execute_all_formats(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature"]);
|
|
|
|
// Test all execute flag formats: --execute with space, --execute=, -xvalue
|
|
// All should complete branches after the execute value is provided
|
|
let test_cases: &[&[&str]] = &[
|
|
&["wt", "switch", "--execute", "code .", ""], // --execute with space
|
|
&["wt", "switch", "--execute=code .", ""], // --execute= with equals
|
|
&["wt", "switch", "-xcode", ""], // -x fused short form
|
|
];
|
|
|
|
for args in test_cases {
|
|
let output = repo.completion_cmd(args).output().unwrap();
|
|
assert!(output.status.success(), "Failed for args: {:?}", args);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches: Vec<&str> = stdout.lines().collect();
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("feature")),
|
|
"Missing feature for {:?}: {:?}",
|
|
args,
|
|
branches
|
|
);
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("main")),
|
|
"Missing main for {:?}: {:?}",
|
|
args,
|
|
branches
|
|
);
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_with_double_dash_terminator(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature"]);
|
|
|
|
// Test: wt switch -- <cursor>
|
|
// After --, everything is positional, should complete branches
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches: Vec<&str> = stdout.lines().collect();
|
|
assert!(branches.iter().any(|b| b.contains("feature")));
|
|
assert!(branches.iter().any(|b| b.contains("main")));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_positional_already_provided(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "existing"]);
|
|
|
|
// Test: wt switch existing <cursor>
|
|
// Positional already provided, should NOT complete branches
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "existing", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"expected only option suggestions, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_completing_execute_value(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "develop"]);
|
|
|
|
// Test: wt switch --execute <cursor>
|
|
// Currently typing the value for --execute, should NOT complete branches
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--execute", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
// Should not suggest branches when completing option value
|
|
assert_eq!(stdout.trim(), "");
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_merge_with_flags(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "hotfix"]);
|
|
|
|
// Test: wt merge --no-remove --yes <cursor>
|
|
// Should complete branches for positional (boolean flags don't consume arguments)
|
|
let output = repo
|
|
.completion_cmd(&["wt", "merge", "--no-remove", "--yes", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches: Vec<&str> = stdout.lines().collect();
|
|
assert!(branches.iter().any(|b| b.contains("hotfix")));
|
|
assert!(branches.iter().any(|b| b.contains("main")));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_base_after_execute_equals(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches
|
|
repo.run_git(&["branch", "develop"]);
|
|
repo.run_git(&["branch", "production"]);
|
|
|
|
// Test: wt switch --create --execute=claude --base <cursor>
|
|
// This is the reported failing case - should complete branches for --base
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--create", "--execute=claude", "--base", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches = value_suggestions(&stdout);
|
|
|
|
// Should show all branches as potential base
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("develop")),
|
|
"Should complete develop branch for --base flag, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("production")),
|
|
"Should complete production branch for --base flag, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("main")),
|
|
"Should complete main branch for --base flag, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_flexible_argument_ordering(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "develop"]);
|
|
|
|
// Test that .last(true) allows positional before flags
|
|
// wt switch feature --base <cursor>
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "feature", "--base", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let branches = value_suggestions(&stdout);
|
|
|
|
// Should complete --base value even when positional comes first
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("develop")),
|
|
"Should complete branches for --base even after positional arg, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
branches.iter().any(|b| b.contains("main")),
|
|
"Should complete branches for --base even after positional arg, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_remove_flexible_argument_ordering(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create two worktrees
|
|
repo.add_worktree("feature");
|
|
repo.add_worktree("bugfix");
|
|
|
|
// Test that .last(true) allows positional before flags
|
|
// wt remove feature --no-delete-branch <cursor>
|
|
// Since remove accepts multiple worktrees, should suggest more worktrees
|
|
let output = repo
|
|
.completion_cmd(&["wt", "remove", "feature", "--no-delete-branch", ""])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let suggestions = value_suggestions(&stdout);
|
|
|
|
// Should suggest additional worktrees (remove accepts Vec<String>)
|
|
assert!(
|
|
suggestions.iter().any(|s| s.contains("bugfix")),
|
|
"Should suggest additional worktrees after positional and flag, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_filters_options_when_positionals_exist(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
repo.run_git(&["branch", "feature"]);
|
|
|
|
// Test: wt switch <cursor>
|
|
// Should show branches but NOT options like --config, --verbose, -C
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should have branch completions
|
|
assert!(stdout.contains("feature"));
|
|
assert!(stdout.contains("main"));
|
|
|
|
// Should NOT have options (they're filtered out when positionals exist)
|
|
assert!(
|
|
!has_any_options(&stdout),
|
|
"Options should be filtered out when positional completions exist, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_subcommands_filter_options(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Test: wt <cursor>
|
|
// Should show subcommands but NOT global options
|
|
let output = repo.completion_cmd(&["wt", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let suggestions = value_suggestions(&stdout);
|
|
|
|
// Should have subcommands
|
|
assert!(suggestions.contains(&"switch"));
|
|
assert!(suggestions.contains(&"list"));
|
|
assert!(suggestions.contains(&"merge"));
|
|
|
|
// Should NOT have global options
|
|
assert!(
|
|
!has_any_options(&stdout),
|
|
"Global options should be filtered out at subcommand position, got:\n{stdout}"
|
|
);
|
|
|
|
// Test: wt --<cursor>
|
|
// Now options SHOULD appear
|
|
let output = repo.completion_cmd(&["wt", "--"]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
has_any_options(&stdout),
|
|
"Options should appear when explicitly completing with --, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_option_prefix_shows_options(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create branches that happen to contain "-c" in the name
|
|
repo.run_git(&["branch", "fish-switch-complete"]);
|
|
repo.run_git(&["branch", "zsh-bash-complete"]);
|
|
|
|
// Test: wt switch --c<cursor>
|
|
// Should show options starting with --c (like --create), NOT branches containing "-c"
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "--c"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should NOT show branches (user is typing an option)
|
|
assert!(
|
|
!stdout.contains("fish-switch-complete"),
|
|
"Should not show branches when completing options, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("zsh-bash-complete"),
|
|
"Should not show branches when completing options, got:\n{stdout}"
|
|
);
|
|
|
|
// Should show options (--create, --config, etc.)
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"Should only show options when input starts with --, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_single_dash_shows_options_not_branches(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create a branch that contains "-" in the name
|
|
repo.run_git(&["branch", "feature-branch"]);
|
|
|
|
// Test: wt switch -<cursor>
|
|
// Should show short options, NOT branches containing "-"
|
|
let output = repo
|
|
.completion_cmd(&["wt", "switch", "-"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should NOT show branches
|
|
assert!(
|
|
!stdout.contains("feature-branch"),
|
|
"Should not show branches when completing options, got:\n{stdout}"
|
|
);
|
|
|
|
// Should show options
|
|
assert!(
|
|
only_option_suggestions(&stdout),
|
|
"Should only show options when input starts with -, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// Verify --help appears in completions across all supported shells.
|
|
///
|
|
/// This is a regression test for a bug where --help was missing from zsh completions
|
|
/// because clap's built-in help flag was disabled (to participate in completion filtering)
|
|
/// but not replaced with a visible alternative.
|
|
#[rstest]
|
|
fn test_complete_help_flag_all_shells(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
for shell in ["bash", "zsh", "fish", "nu"] {
|
|
// Test: wt --help<cursor> - should complete --help
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "--help"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--help"),
|
|
"{shell}: --help should appear in completions for 'wt --help', got:\n{stdout}"
|
|
);
|
|
|
|
// Test: wt config --help<cursor> - should complete --help on subcommands too
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "config", "--help"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--help"),
|
|
"{shell}: --help should appear in completions for 'wt config --help', got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Verify --version appears in completions across all supported shells (root command only).
|
|
#[rstest]
|
|
fn test_complete_version_flag_all_shells(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
for shell in ["bash", "zsh", "fish", "nu"] {
|
|
// Test: wt --version<cursor> - should complete --version
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "--version"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--version"),
|
|
"{shell}: --version should appear in completions for 'wt --version', got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Verify single dash '-' shows both short AND long flags.
|
|
///
|
|
/// When completing `wt -`, users should see both short flags like `-h` and long flags
|
|
/// like `--help`. This is more discoverable than requiring users to type `--` first.
|
|
#[rstest]
|
|
fn test_complete_single_dash_shows_both_short_and_long_flags(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
for shell in ["bash", "zsh", "fish", "nu"] {
|
|
// Test: wt -<cursor> - should show both -h and --help
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "-"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Should have short flags
|
|
assert!(
|
|
stdout.contains("-h"),
|
|
"{shell}: single dash should show -h, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("-v") || stdout.contains("-V"),
|
|
"{shell}: single dash should show -v or -V, got:\n{stdout}"
|
|
);
|
|
|
|
// Should also have long flags
|
|
assert!(
|
|
stdout.contains("--help"),
|
|
"{shell}: single dash should show --help, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("--verbose") || stdout.contains("--version"),
|
|
"{shell}: single dash should show --verbose or --version, got:\n{stdout}"
|
|
);
|
|
|
|
// Test: wt config -<cursor> - same behavior on subcommands
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "config", "-"], shell)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{shell}: completion failed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("-h") && stdout.contains("--help"),
|
|
"{shell}: subcommand single dash should show both -h and --help, got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `wt config alias show <TAB>` and `wt config alias dry-run <TAB>` complete
|
|
/// with the merged user + project alias name set.
|
|
#[rstest]
|
|
fn test_config_alias_name_completion(repo: TestRepo) {
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
deploy = "echo deploying"
|
|
test = "cargo test"
|
|
"#,
|
|
);
|
|
repo.commit("Add alias config");
|
|
|
|
for subcommand in ["show", "dry-run"] {
|
|
let output = repo
|
|
.completion_cmd(&["wt", "config", "alias", subcommand, ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"completion for `wt config alias {subcommand} <TAB>` failed"
|
|
);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let values = value_suggestions(&stdout);
|
|
assert!(
|
|
values.contains(&"deploy"),
|
|
"`wt config alias {subcommand}` should suggest configured aliases, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
values.contains(&"test"),
|
|
"`wt config alias {subcommand}` should suggest configured aliases, got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test shell completions command for package managers.
|
|
///
|
|
/// The `wt config shell completions <shell>` command outputs completion scripts
|
|
/// suitable for package manager integration (e.g., Homebrew's
|
|
/// `generate_completions_from_executable`). For bash and zsh the output is a
|
|
/// *dynamic* registration that calls the binary at TAB time, so branch and
|
|
/// worktree names complete on a plain package install.
|
|
#[rstest]
|
|
fn test_completions_for_all_shells() {
|
|
// Test each supported shell produces valid output
|
|
for shell in ["bash", "fish", "nu", "zsh", "powershell"] {
|
|
let output = wt_command()
|
|
.args(["config", "shell", "completions", shell])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"{shell}: completions command failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.is_empty(),
|
|
"{shell}: completions output should not be empty"
|
|
);
|
|
|
|
// Each shell should have some indication it's a completion script
|
|
match shell {
|
|
"bash" => {
|
|
assert!(
|
|
stdout.contains("complete") || stdout.contains("_wt"),
|
|
"{shell}: should contain bash completion markers"
|
|
);
|
|
// Dynamic registration: clap's runtime completer function plus a
|
|
// `complete -F` binding that calls the binary at TAB time.
|
|
assert!(
|
|
stdout.contains("_clap_complete_wt"),
|
|
"{shell}: should contain clap's dynamic completer function, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("complete -") && stdout.contains("-F _clap_complete_wt wt"),
|
|
"{shell}: should register the dynamic completer via complete -F, got:\n{stdout}"
|
|
);
|
|
// The old static output built `COMPREPLY` from a hardcoded word list via
|
|
// `compgen`. Dynamic output asks the binary for candidates instead.
|
|
assert!(
|
|
!stdout.contains("compgen"),
|
|
"{shell}: should not contain static compgen boilerplate, got:\n{stdout}"
|
|
);
|
|
}
|
|
"fish" => {
|
|
assert!(
|
|
stdout.contains("complete") && stdout.contains("wt"),
|
|
"{shell}: should contain fish completion markers"
|
|
);
|
|
// Dynamic registration: a single `complete --command wt` whose argument
|
|
// source calls the binary at TAB time. The static output instead listed
|
|
// subcommands via `-n`/`__fish_*` predicates.
|
|
assert!(
|
|
stdout.contains("--command wt") && stdout.contains("COMPLETE=fish"),
|
|
"{shell}: should call the binary at TAB time, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("__fish_"),
|
|
"{shell}: should not contain static __fish_ predicate boilerplate, got:\n{stdout}"
|
|
);
|
|
// Regression for #3240: the registration must resolve the real
|
|
// binary (via `type -P`) rather than calling the bare `wt`
|
|
// command. When worktrunk's lazy-load wrapper function shadows
|
|
// the binary, a bare `COMPLETE=fish wt -- …` re-enters the
|
|
// wrapper. fish has already set `COMPLETE=fish`, so the wrapper's
|
|
// `command wt config shell init fish | source` emits completions
|
|
// instead of the init script — the real function is never
|
|
// defined — and the wrapper's trailing `wt $argv` recurses to
|
|
// fish's call-stack limit.
|
|
assert!(
|
|
stdout.contains("type -P wt") && stdout.contains("WORKTRUNK_BIN"),
|
|
"{shell}: completion must resolve the binary path to bypass the wrapper function (#3240), got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("COMPLETE=fish wt "),
|
|
"{shell}: completion must not call the bare `wt` command — it recurses through the lazy-load wrapper (#3240), got:\n{stdout}"
|
|
);
|
|
}
|
|
"zsh" => {
|
|
assert!(
|
|
stdout.contains("#compdef") || stdout.contains("_wt"),
|
|
"{shell}: should contain zsh completion markers"
|
|
);
|
|
// Dynamic registration: the `#compdef` autoload marker, clap's runtime
|
|
// completer that calls the binary at TAB time, and the autoload guard.
|
|
assert!(
|
|
stdout.contains("#compdef wt"),
|
|
"{shell}: should contain the #compdef autoload marker, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains(r#"COMPLETE="zsh""#)
|
|
&& stdout.contains(r#"wt -- "${words[@]}""#),
|
|
"{shell}: should call the binary at TAB time, got:\n{stdout}"
|
|
);
|
|
// Dual-mode guard: autoloaded from fpath -> complete directly; sourced -> compdef.
|
|
// `funcstack[1]` is the discriminating token (absent from clap's raw output);
|
|
// the `compdef` must sit *inside* the guard (after the funcstack check), not as
|
|
// clap's bare trailing line. That ordering is what proves the transform applied.
|
|
let funcstack_pos = stdout
|
|
.find("funcstack[1]")
|
|
.expect("dynamic zsh registration should contain the autoload guard");
|
|
let compdef_pos = stdout
|
|
.find("compdef _clap_dynamic_completer_wt wt")
|
|
.expect("guard should register via compdef when sourced");
|
|
assert!(
|
|
funcstack_pos < compdef_pos,
|
|
"{shell}: compdef must sit inside the guard, after the funcstack check, got:\n{stdout}"
|
|
);
|
|
// Single-column display zstyles, matched to `templates/zsh.zsh`.
|
|
assert!(
|
|
stdout.contains("list-max 1") && stdout.contains("list-grouped false"),
|
|
"{shell}: should append the single-column display zstyles, got:\n{stdout}"
|
|
);
|
|
// The zstyles must precede the guard. In autoload mode the whole file body
|
|
// runs as `_wt` on every completion, so setting the styles before the
|
|
// completer call is what makes single-column listing apply on the FIRST TAB.
|
|
// Regression guard for the bug where the zstyles followed the guard.
|
|
let zstyle_pos = stdout.find("list-max 1").unwrap();
|
|
let guard_pos = stdout.find("funcstack[1]").unwrap();
|
|
assert!(
|
|
zstyle_pos < guard_pos,
|
|
"{shell}: display zstyles must come before the autoload guard, got:\n{stdout}"
|
|
);
|
|
// The old static output drove completion from `_arguments`. Dynamic output
|
|
// builds the candidate list from the binary instead.
|
|
assert!(
|
|
!stdout.contains("_arguments"),
|
|
"{shell}: should not contain static _arguments boilerplate, got:\n{stdout}"
|
|
);
|
|
}
|
|
"nu" => {
|
|
// Nushell uses template-based integration, not clap_complete
|
|
assert!(
|
|
stdout.contains("def --wrapped") || stdout.contains("def --env"),
|
|
"{shell}: should contain nushell function markers"
|
|
);
|
|
assert!(
|
|
stdout.contains("nu-complete wt"),
|
|
"{shell}: should contain nushell completer function"
|
|
);
|
|
}
|
|
"powershell" => {
|
|
assert!(
|
|
stdout.contains("Register-ArgumentCompleter")
|
|
|| stdout.contains("$scriptBlock"),
|
|
"{shell}: should contain PowerShell completion markers"
|
|
);
|
|
// Dynamic registration: the completer invokes the binary at TAB time
|
|
// (sets COMPLETE and calls `wt`), rather than embedding a static word list.
|
|
assert!(
|
|
stdout.contains(r#"COMPLETE = "powershell""#) && stdout.contains("wt"),
|
|
"{shell}: should call the binary at TAB time, got:\n{stdout}"
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_shows_all_remotes_for_ambiguous_branch(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Set up two remotes: origin and upstream
|
|
repo.setup_remote("main");
|
|
repo.setup_custom_remote("upstream", "main");
|
|
|
|
// Create a branch locally and push to both remotes
|
|
repo.run_git(&["checkout", "-b", "shared-feature"]);
|
|
repo.commit_with_message("Add shared feature");
|
|
repo.run_git(&["push", "origin", "shared-feature"]);
|
|
repo.run_git(&["push", "upstream", "shared-feature"]);
|
|
|
|
// Delete local branch so it only exists on remotes
|
|
repo.run_git(&["checkout", "main"]);
|
|
repo.run_git(&["branch", "-D", "shared-feature"]);
|
|
|
|
// Test completion with fish shell to see help text (bash doesn't show descriptions)
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", ""], "fish")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// The branch should appear with both remotes listed
|
|
// Format: "shared-feature\t⇣ <time> origin, upstream" (sorted alphabetically)
|
|
assert!(
|
|
stdout.contains("shared-feature"),
|
|
"Should show shared-feature branch: {stdout}"
|
|
);
|
|
// Check that both remotes are shown (order is alphabetical: origin, upstream)
|
|
assert!(
|
|
stdout.contains("origin") && stdout.contains("upstream"),
|
|
"Should show both remotes for ambiguous branch: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_excludes_remote_branches_when_over_threshold(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.setup_remote("main");
|
|
|
|
// Only the ref COUNT matters here, so the whole fixture is built in five
|
|
// git spawns rather than one per branch (was ~230, and every one of them
|
|
// competes with the rest of the suite running at full core parallelism).
|
|
let local: Vec<String> = (0..50).map(|i| format!("local/branch-{i}")).collect();
|
|
let remote: Vec<String> = (0..60).map(|i| format!("remote/branch-{i}")).collect();
|
|
repo.create_branches(&local);
|
|
repo.create_branches(&remote);
|
|
|
|
// Push all 60 in one call, then delete all 60 locally in one call, so
|
|
// they survive only as remote-tracking refs.
|
|
let mut push = vec!["push", "origin"];
|
|
push.extend(remote.iter().map(String::as_str));
|
|
repo.run_git(&push);
|
|
let mut delete = vec!["branch", "-D"];
|
|
delete.extend(remote.iter().map(String::as_str));
|
|
repo.run_git(&delete);
|
|
repo.run_git(&["fetch", "origin"]);
|
|
|
|
// Total branches: 1 (main worktree) + 50 local + 60 remote = 111 > 100
|
|
let output = repo.completion_cmd(&["wt", "switch", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let suggestions = value_suggestions(&stdout);
|
|
|
|
// Local branches should still appear
|
|
assert!(
|
|
suggestions.iter().any(|s| s.contains("local/branch-0")),
|
|
"Local branches should appear in completions: {stdout}"
|
|
);
|
|
|
|
// Remote-only branches should be excluded (threshold exceeded)
|
|
assert!(
|
|
!suggestions.iter().any(|s| s.contains("remote/branch-")),
|
|
"Remote-only branches should be excluded when total > 100: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_switch_includes_remote_branches_when_under_threshold(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.setup_remote("main");
|
|
|
|
// Create a few local branches
|
|
for i in 0..5 {
|
|
repo.run_git(&["branch", &format!("local/branch-{i}")]);
|
|
}
|
|
|
|
// Create a few remote-only branches
|
|
for i in 0..3 {
|
|
let name = format!("remote/branch-{i}");
|
|
repo.run_git(&["branch", &name]);
|
|
repo.run_git(&["push", "origin", &name]);
|
|
repo.run_git(&["branch", "-D", &name]);
|
|
}
|
|
repo.run_git(&["fetch", "origin"]);
|
|
|
|
// Total branches: 1 (main) + 5 local + 3 remote = 9 < 100
|
|
let output = repo
|
|
.completion_cmd_for_shell(&["wt", "switch", ""], "fish")
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
// Both local and remote branches should appear (under threshold)
|
|
assert!(
|
|
stdout.contains("local/branch-0"),
|
|
"Local branches should appear: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("remote/branch-0"),
|
|
"Remote branches should appear when total <= 100: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_shows_aliases_from_project_config(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
deploy = "make deploy"
|
|
lint = "cargo clippy"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
|
|
let output = repo.completion_cmd(&["wt", "step", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
|
|
// Built-in commands still present
|
|
assert!(subcommands.contains(&"commit"), "Missing commit");
|
|
assert!(subcommands.contains(&"push"), "Missing push");
|
|
// Aliases appear
|
|
assert!(
|
|
subcommands.contains(&"deploy"),
|
|
"Missing alias 'deploy': {stdout}"
|
|
);
|
|
assert!(
|
|
subcommands.contains(&"lint"),
|
|
"Missing alias 'lint': {stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_shows_aliases_from_user_config(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_test_config(
|
|
r#"
|
|
[aliases]
|
|
update = "git pull --rebase"
|
|
"#,
|
|
);
|
|
|
|
let output = repo.completion_cmd(&["wt", "step", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
|
|
assert!(
|
|
subcommands.contains(&"update"),
|
|
"Missing user alias 'update': {stdout}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_alias_does_not_shadow_builtins(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
commit = "echo 'shadowed'"
|
|
deploy = "make deploy"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
|
|
let output = repo.completion_cmd(&["wt", "step", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let subcommands = value_suggestions(&stdout);
|
|
|
|
// 'commit' should appear exactly once (the built-in), not duplicated
|
|
let commit_count = subcommands.iter().filter(|&&s| s == "commit").count();
|
|
assert_eq!(commit_count, 1, "Built-in 'commit' should appear once");
|
|
// 'deploy' alias should appear
|
|
assert!(subcommands.contains(&"deploy"));
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_complete_step_alias_shows_flags(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
deploy = "make deploy"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
|
|
// Complete flags for the alias subcommand
|
|
let output = repo
|
|
.completion_cmd(&["wt", "step", "deploy", "--"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("--dry-run"),
|
|
"Missing --dry-run flag: {stdout}"
|
|
);
|
|
assert!(stdout.contains("--yes"), "Missing --yes flag: {stdout}");
|
|
assert!(stdout.contains("--var"), "Missing --var flag: {stdout}");
|
|
}
|
|
|
|
// --- Alias argument-completion mirroring -------------------------------------
|
|
//
|
|
// An alias that forwards `{{ args }}` to a `wt` leaf built-in (`co = "wt switch
|
|
// {{ args }}"`) mirrors that built-in's completion instead of the generic stub.
|
|
// Detection: exactly one `{{ args }}`-forwarding command, leading `wt`, then a
|
|
// tree-walk that lands on a leaf. See `mirror_builtin_leaf` in src/completion.rs.
|
|
|
|
/// `wt co <Tab>` mirrors `wt switch <Tab>`: the positional completes branches,
|
|
/// switch's flags are offered after `--`, and a bare positional tab does not
|
|
/// spam flags (`hide_non_positional_options_for_completion` ordering).
|
|
#[rstest]
|
|
fn test_complete_alias_mirrors_wrapped_switch(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
co = "wt switch {{ args }}"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
// Bare positional → branch candidates (mirrored completer), no flag spam.
|
|
let output = repo.completion_cmd(&["wt", "co", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("feature/new"),
|
|
"mirrored switch should complete branches: {stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("--create"),
|
|
"bare tab must not list switch's flags: {stdout}"
|
|
);
|
|
|
|
// Flag prefix → switch's flags.
|
|
let output = repo.completion_cmd(&["wt", "co", "--"]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--create"),
|
|
"mirrored switch should offer --create: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// A baked-in flag in the template (`mk = "wt switch --create {{ args }}"`)
|
|
/// stops the tree-walk at `switch` but still mirrors it.
|
|
#[rstest]
|
|
fn test_complete_alias_mirrors_switch_with_baked_flag(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
mk = "wt switch --create {{ args }}"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
|
|
let output = repo.completion_cmd(&["wt", "mk", "--"]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--create"),
|
|
"mk should offer --create: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("--base"),
|
|
"mk should offer --base: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// A nested leaf (`cm = "wt step commit {{ args }}"`) mirrors `step commit`:
|
|
/// its flags appear (not the generic stub's), and `--branch`'s value completes
|
|
/// via commit's worktree-only completer.
|
|
#[rstest]
|
|
fn test_complete_alias_mirrors_nested_step_command(mut repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
cm = "wt step commit {{ args }}"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
// `wt step commit`'s `--branch` uses `worktree_only_completer`, so create a
|
|
// worktree (which also creates its branch).
|
|
repo.add_worktree("feature/new");
|
|
|
|
// Flag prefix → step-commit's own flags (not the generic stub's --var).
|
|
let output = repo.completion_cmd(&["wt", "cm", "--"]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--stage"),
|
|
"mirrored step-commit should offer --stage: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("--show-prompt"),
|
|
"mirrored step-commit should offer --show-prompt: {stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("--var"),
|
|
"mirrored step-commit must not expose the generic stub's --var: {stdout}"
|
|
);
|
|
|
|
// `--branch <value>` completes via commit's worktree_only_completer.
|
|
let output = repo
|
|
.completion_cmd(&["wt", "cm", "--branch", ""])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("feature/new"),
|
|
"mirrored step-commit --branch should complete worktree branches: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// An alias that does not wrap a `wt` built-in falls back to the generic stub:
|
|
/// no branch completion.
|
|
#[rstest]
|
|
fn test_complete_alias_falls_back_when_not_wrapping_builtin(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
greet = "echo hi {{ branch }}"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
let output = repo.completion_cmd(&["wt", "greet", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("feature/new"),
|
|
"non-wrapping alias must not complete branches: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// A bare dispatcher (`s = "wt step {{ args }}"`) lands on a non-leaf and falls
|
|
/// back to the generic stub — it must NOT offer `step`'s subcommands.
|
|
#[rstest]
|
|
fn test_complete_alias_bare_dispatcher_falls_back(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
s = "wt step {{ args }}"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
|
|
let output = repo.completion_cmd(&["wt", "s", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("commit"),
|
|
"bare wt step wrapper must not mirror step's subcommands: {stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("diff"),
|
|
"bare wt step wrapper must not mirror step's subcommands: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// Multiple commands forwarding `{{ args }}` is ambiguous → generic stub.
|
|
#[rstest]
|
|
fn test_complete_alias_multiple_forwarders_fall_back(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
both = ["wt switch {{ args }}", "echo {{ args }}"]
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
let output = repo.completion_cmd(&["wt", "both", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("feature/new"),
|
|
"ambiguous multi-forwarder alias must not mirror: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// An alias whose command doesn't reference `{{ args }}` doesn't forward CLI
|
|
/// positionals → generic stub.
|
|
#[rstest]
|
|
fn test_complete_alias_no_args_forwarding_falls_back(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
co = "wt switch --create main"
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
let output = repo.completion_cmd(&["wt", "co", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("feature/new"),
|
|
"alias that doesn't forward {{ args }} must not mirror: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// The single forwarder need not be the first command — a pipeline that runs a
|
|
/// prep step then `wt switch {{ args }}` still mirrors switch.
|
|
#[rstest]
|
|
fn test_complete_alias_mirrors_when_forwarder_not_first(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
repo.write_project_config(
|
|
r#"
|
|
[aliases]
|
|
pre = ["npm install", "wt switch {{ args }}"]
|
|
"#,
|
|
);
|
|
repo.commit("add config");
|
|
repo.run_git(&["branch", "feature/new"]);
|
|
|
|
let output = repo.completion_cmd(&["wt", "pre", ""]).output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("feature/new"),
|
|
"forwarder-not-first alias should still mirror switch: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// Prepend a directory to PATH on a Command.
|
|
fn prepend_path(cmd: &mut std::process::Command, dir: &std::path::Path) {
|
|
let (path_var, current) = std::env::vars_os()
|
|
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
|
|
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
|
|
.unwrap_or(("PATH".to_string(), None));
|
|
|
|
let mut paths: Vec<std::path::PathBuf> = current
|
|
.as_deref()
|
|
.map(|p| std::env::split_paths(p).collect())
|
|
.unwrap_or_default();
|
|
paths.insert(0, dir.to_path_buf());
|
|
let new_path = std::env::join_paths(&paths).unwrap();
|
|
cmd.env(path_var, new_path);
|
|
}
|
|
|
|
/// External `wt-*` binaries on PATH appear as subcommand completion candidates.
|
|
#[rstest]
|
|
fn test_complete_custom_subcommand_listed(repo: TestRepo) {
|
|
repo.commit("initial");
|
|
|
|
// Create a mock wt-testext binary on PATH
|
|
let ext_dir = tempfile::tempdir().unwrap();
|
|
MockConfig::new("wt-testext")
|
|
.command("_default", MockResponse::output("external ran\n"))
|
|
.write(ext_dir.path());
|
|
|
|
// Complete "wt " — should include "testext" from the `wt-testext` binary
|
|
let mut cmd = repo.completion_cmd(&["wt", ""]);
|
|
prepend_path(&mut cmd, ext_dir.path());
|
|
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", ext_dir.path());
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("testext"),
|
|
"Custom subcommand 'testext' missing from completion output: {stdout}"
|
|
);
|
|
// Built-in subcommands should still be present
|
|
assert!(
|
|
stdout.contains("switch"),
|
|
"Built-in 'switch' missing from completion output: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// Completion for a custom subcommand's flags forwards to the `wt-*` binary.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_complete_custom_subcommand_forwards(repo: TestRepo) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use worktrunk::shell_exec::RETIRED_DIRECTIVE_FILE_ENV_VAR;
|
|
|
|
repo.commit("initial");
|
|
|
|
// Create a real shell script that outputs completions (not a mock_commands
|
|
// mock, which needs WORKTRUNK_TEST_MOCK_CONFIG_DIR and plays back its config
|
|
// instead of answering the COMPLETE env var).
|
|
let ext_dir = tempfile::tempdir().unwrap();
|
|
let retired_file = ext_dir.path().join("retired");
|
|
std::fs::write(&retired_file, "").unwrap();
|
|
let script = ext_dir.path().join("wt-testext");
|
|
std::fs::write(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
if [ -n "${WORKTRUNK_DIRECTIVE_FILE+x}" ]; then
|
|
printf 'retired write\n' >> "$WORKTRUNK_DIRECTIVE_FILE"
|
|
fi
|
|
printf '%s\n%s\n%s' '--custom-flag' '--another' "retired:${WORKTRUNK_DIRECTIVE_FILE-unset}"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
// Complete "wt testext --" — should forward to wt-testext and show its output
|
|
let mut cmd = repo.completion_cmd(&["wt", "testext", "--"]);
|
|
prepend_path(&mut cmd, ext_dir.path());
|
|
cmd.env(RETIRED_DIRECTIVE_FILE_ENV_VAR, &retired_file);
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("--custom-flag"),
|
|
"Forwarded completion output missing '--custom-flag': {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("retired:unset"),
|
|
"Forwarded completion received the retired directive variable: {stdout}"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&retired_file).unwrap(),
|
|
"",
|
|
"the retired directive file must remain untouched"
|
|
);
|
|
}
|
|
|
|
/// Regression for #3816: `config shell init --cmd <name>` must generate a
|
|
/// completion loader whose clap-derived identifiers match the ones the binary
|
|
/// emits at TAB time.
|
|
///
|
|
/// The loader is lazy — the first TAB evals `COMPLETE=<shell> <binary>` and
|
|
/// then calls clap's completer function. clap derives that function's name
|
|
/// from its own `Command` name, so before the fix the loader guarded on and
|
|
/// called `_clap_complete_wot` while the eval only ever defined
|
|
/// `_clap_complete_wt`: nothing completed, and because the guard never became
|
|
/// true the script was re-generated and re-evaluated on every TAB.
|
|
///
|
|
/// Driven through a real bash so the whole chain is exercised: init output →
|
|
/// loader function → eval → clap's completer must exist afterwards.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
#[case("wot", "_clap_complete_wot")]
|
|
// A `-` is not valid in a clap-escaped function name, so the documented
|
|
// `--cmd=git-wt` case needs the same `-` → `_` escaping clap applies.
|
|
#[case("git-wt", "_clap_complete_git_wt")]
|
|
fn test_init_custom_cmd_defines_clap_completer_in_bash(
|
|
#[case] cmd_name: &str,
|
|
#[case] clap_fn: &str,
|
|
) {
|
|
let wt = crate::common::wt_bin();
|
|
let wt = wt.to_str().unwrap();
|
|
let script = format!(
|
|
r#"export WORKTRUNK_BIN="{wt}"
|
|
eval "$("{wt}" config shell init bash --cmd {cmd_name})"
|
|
COMP_WORDS=({cmd_name} ""); COMP_CWORD=1
|
|
_{cmd_name}_lazy_complete {cmd_name} "" {cmd_name} >/dev/null 2>&1
|
|
declare -F {clap_fn} >/dev/null && echo DEFINED || echo MISSING
|
|
"#
|
|
);
|
|
|
|
let output = std::process::Command::new("bash")
|
|
.arg("-c")
|
|
.arg(&script)
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("DEFINED"),
|
|
"`--cmd {cmd_name}` loader must define {clap_fn} after its lazy eval, got:\n{stdout}\n----- stderr -----\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
/// Companion to the bash end-to-end test above, for the shells that can't be
|
|
/// driven from CI. The registration the binary emits must name the command the
|
|
/// shell integration was generated for, not clap's own `wt`.
|
|
///
|
|
/// zsh's registration also ends with `compdef <completer> <cmd>`, which binds
|
|
/// worktrunk's completer to whatever name it carries — with clap's `wt` that
|
|
/// hijacked completions for the *other* `wt` on PATH, the exact conflict
|
|
/// `--cmd` exists to avoid.
|
|
#[rstest]
|
|
#[case(
|
|
"zsh",
|
|
"wot",
|
|
"_clap_dynamic_completer_wot",
|
|
"compdef _clap_dynamic_completer_wot wot"
|
|
)]
|
|
#[case(
|
|
"zsh",
|
|
"git-wt",
|
|
"_clap_dynamic_completer_git_wt",
|
|
"compdef _clap_dynamic_completer_git_wt git-wt"
|
|
)]
|
|
#[case("bash", "wot", "_clap_complete_wot", "-F _clap_complete_wot wot")]
|
|
#[case("powershell", "wot", "Register-ArgumentCompleter", "-CommandName wot")]
|
|
fn test_completion_registration_uses_shell_integration_cmd_name(
|
|
#[case] shell: &str,
|
|
#[case] cmd_name: &str,
|
|
#[case] completer_fn: &str,
|
|
#[case] registration: &str,
|
|
) {
|
|
// The generated init script must reference the same identifier…
|
|
let init = wt_command()
|
|
.args(["config", "shell", "init", shell, "--cmd", cmd_name])
|
|
.output()
|
|
.unwrap();
|
|
let init_stdout = String::from_utf8_lossy(&init.stdout);
|
|
if shell != "powershell" {
|
|
assert!(
|
|
init_stdout.contains(completer_fn),
|
|
"{shell}: init --cmd {cmd_name} should reference {completer_fn}, got:\n{init_stdout}"
|
|
);
|
|
}
|
|
|
|
// …that the binary actually defines when the loader evals it.
|
|
let output = wt_command()
|
|
.env("COMPLETE", shell)
|
|
.env("WORKTRUNK_COMPLETE_NAME", cmd_name)
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains(completer_fn),
|
|
"{shell}: registration should define {completer_fn}, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains(registration),
|
|
"{shell}: registration should bind via `{registration}`, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// `WORKTRUNK_COMPLETE_NAME` lands verbatim in generated shell code, so it is
|
|
/// validated before it gets there. wt's own shell integration only ever sets it
|
|
/// to a name `--cmd` already validated; a value outside the allowlist means
|
|
/// something else set it, and the registration falls back to the binary name
|
|
/// rather than emitting an unusable — or injected — identifier.
|
|
#[rstest]
|
|
#[case("wt; rm -rf /")]
|
|
#[case("wt$(id)")]
|
|
#[case("git wt")]
|
|
#[case("-wt")]
|
|
#[case("")]
|
|
fn test_completion_registration_rejects_invalid_complete_name(#[case] name: &str) {
|
|
let output = wt_command()
|
|
.env("COMPLETE", "bash")
|
|
.env("WORKTRUNK_COMPLETE_NAME", name)
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("-F _clap_complete_wt wt"),
|
|
"an invalid WORKTRUNK_COMPLETE_NAME ({name:?}) must fall back to the binary name, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
name.is_empty() || !stdout.contains(name),
|
|
"a rejected WORKTRUNK_COMPLETE_NAME ({name:?}) must not reach the generated script:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// The binary-name fallback is validated for the same reason the env var is:
|
|
/// it is `argv[0]`'s file stem, which no `--cmd` gate has seen. A binary
|
|
/// installed under a name outside the allowlist would otherwise register
|
|
/// `complete -F _clap_complete_<name>` with an identifier bash can't parse, so
|
|
/// the last resort is the compile-time `wt`.
|
|
///
|
|
/// Unix-only because it needs a symlink named for the invalid case; the
|
|
/// validation itself is platform-independent.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_completion_registration_falls_back_when_binary_name_is_invalid() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let link = dir.path().join("wt+odd");
|
|
std::os::unix::fs::symlink(crate::common::wt_bin(), &link).unwrap();
|
|
|
|
// Borrow the isolated environment `wt_command` sets up — only the program
|
|
// itself differs, and `Command` can't have its program swapped.
|
|
let template = wt_command();
|
|
let mut cmd = std::process::Command::new(&link);
|
|
for (key, value) in template.get_envs() {
|
|
match value {
|
|
Some(value) => cmd.env(key, value),
|
|
None => cmd.env_remove(key),
|
|
};
|
|
}
|
|
if let Some(cwd) = template.get_current_dir() {
|
|
cmd.current_dir(cwd);
|
|
}
|
|
|
|
let output = cmd.env("COMPLETE", "bash").output().unwrap();
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
stdout.contains("-F _clap_complete_wt wt"),
|
|
"an invalid binary name must fall back to `wt`, got:\n{stdout}"
|
|
);
|
|
}
|