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");
repo.run_git(&["branch", "feature/new"]);
repo.run_git(&["branch", "hotfix/bug"]);
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_switch_shows_all_branches_including_worktrees(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("feature/new");
repo.run_git(&["branch", "hotfix/bug"]);
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");
repo.add_worktree("feature/new");
repo.run_git(&["branch", "hotfix/bug"]);
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");
repo.run_git(&["branch", "develop"]);
repo.run_git(&["branch", "feature/existing"]);
let test_cases: &[&[&str]] = &[
&["wt", "switch", "--create", "new-branch", "--base", ""], &["wt", "switch", "--create", "new-branch", "-b", ""], &["wt", "switch", "--create", "new-branch", "--base="], &["wt", "switch", "--create", "new-branch", "-b="], ];
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
);
}
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() {
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);
assert!(
stdout
.lines()
.filter(|line| !line.trim().is_empty())
.all(|line| line.starts_with('-')),
"expected only option suggestions in 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);
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() {
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);
assert!(
!stdout.contains("complete --keep-order --exclusive --command wt --arguments"),
"Fish init should NOT have inline completions (they go to separate file)"
);
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");
repo.run_git(&["branch", "feature/one"]);
repo.run_git(&["branch", "feature/two"]);
repo.run_git(&["branch", "hotfix/bug"]);
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);
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")));
}
#[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"]);
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}"
);
}
#[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"]);
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}"
);
}
#[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"]);
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}"
);
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}"
);
}
#[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");
repo.add_worktree("feature/new");
repo.add_worktree("hotfix/bug");
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"));
}
#[rstest]
fn test_complete_excludes_remote_branches(repo: TestRepo) {
repo.commit("initial");
repo.run_git(&["branch", "feature/local"]);
let remote_dir = repo.root_path().parent().unwrap().join("remote.git");
repo.git_command()
.args(["init", "--bare", remote_dir.to_str().unwrap()])
.run()
.unwrap();
repo.run_git(&["remote", "set-url", "origin", remote_dir.to_str().unwrap()]);
repo.run_git(&["push", "origin", "main"]);
repo.run_git(&["push", "origin", "feature/local:feature/remote"]);
repo.run_git(&["fetch", "origin"]);
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/local"),
"Should include feature/local branch, but got: {}",
stdout
);
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");
repo.add_worktree("feature/new");
repo.run_git(&["branch", "hotfix/bug"]);
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();
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");
let branch_names = vec![
"feature/FOO-123", "release/v1.2.3", "hotfix/bug_fix", "feature/multi-part-name", ];
for branch in &branch_names {
repo.run_git(&["branch", branch]);
}
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);
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");
repo.run_git(&["branch", "feature/one"]);
repo.run_git(&["branch", "feature/two"]);
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}"
);
});
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}"
);
});
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"]);
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}"
);
});
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");
repo.run_git(&["branch", "develop"]);
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);
assert!(stdout.contains("develop"));
}
#[rstest]
fn test_complete_remove_excludes_remote_only_branches(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("feature/new");
repo.run_git(&["branch", "hotfix/bug"]);
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();
assert!(branches.iter().any(|b| b.contains("feature/new")));
assert!(branches.iter().any(|b| b.contains("hotfix/bug")));
}
#[rstest]
fn test_complete_step_subcommands(repo: TestRepo) {
repo.commit("initial");
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(&"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_eq!(
subcommands.len(),
11,
"Should have exactly 11 step subcommands"
);
}
#[rstest]
fn test_complete_hook_subcommands(repo: TestRepo) {
repo.commit("initial");
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);
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!(subcommands.contains(&"approvals"), "Missing approvals");
assert_eq!(
subcommands.len(),
12,
"Should have exactly 12 hook 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"));
}
#[rstest]
fn test_hook_command_completion_cross_shell_filtering_contract(repo: TestRepo) {
repo.commit("initial");
repo.write_project_config(
r#"
pre-merge = [
{test = "cargo test"},
{lint = "cargo clippy"},
{build = "cargo 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");
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"));
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"));
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"));
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");
let output = repo
.completion_cmd(&["wt", "list", "--format", ""])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
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"]);
let test_cases: &[&[&str]] = &[
&["wt", "switch", "--execute", "code .", ""], &["wt", "switch", "--execute=code .", ""], &["wt", "switch", "-xcode", ""], ];
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"]);
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"]);
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"]);
let output = repo
.completion_cmd(&["wt", "switch", "--execute", ""])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.trim(), "");
}
#[rstest]
fn test_complete_merge_with_flags(repo: TestRepo) {
repo.commit("initial");
repo.run_git(&["branch", "hotfix"]);
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");
repo.run_git(&["branch", "develop"]);
repo.run_git(&["branch", "production"]);
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);
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"]);
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);
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");
repo.add_worktree("feature");
repo.add_worktree("bugfix");
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);
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"]);
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"));
assert!(stdout.contains("main"));
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");
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);
assert!(suggestions.contains(&"switch"));
assert!(suggestions.contains(&"list"));
assert!(suggestions.contains(&"merge"));
assert!(
!has_any_options(&stdout),
"Global options should be filtered out at subcommand position, got:\n{stdout}"
);
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");
repo.run_git(&["branch", "fish-switch-complete"]);
repo.run_git(&["branch", "zsh-bash-complete"]);
let output = repo
.completion_cmd(&["wt", "switch", "--c"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
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}"
);
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");
repo.run_git(&["branch", "feature-branch"]);
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-branch"),
"Should not show branches when completing options, got:\n{stdout}"
);
assert!(
only_option_suggestions(&stdout),
"Should only show options when input starts with -, got:\n{stdout}"
);
}
#[rstest]
fn test_complete_help_flag_all_shells(repo: TestRepo) {
repo.commit("initial");
for shell in ["bash", "zsh", "fish", "nu"] {
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}"
);
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}"
);
}
}
#[rstest]
fn test_complete_version_flag_all_shells(repo: TestRepo) {
repo.commit("initial");
for shell in ["bash", "zsh", "fish", "nu"] {
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}"
);
}
}
#[rstest]
fn test_complete_single_dash_shows_both_short_and_long_flags(repo: TestRepo) {
repo.commit("initial");
for shell in ["bash", "zsh", "fish", "nu"] {
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);
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}"
);
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}"
);
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}"
);
}
}
#[rstest]
fn test_static_completions_for_all_shells() {
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"
);
match shell {
"bash" => {
assert!(
stdout.contains("complete") || stdout.contains("_wt"),
"{shell}: should contain bash completion markers"
);
}
"fish" => {
assert!(
stdout.contains("complete") && stdout.contains("wt"),
"{shell}: should contain fish completion markers"
);
}
"zsh" => {
assert!(
stdout.contains("#compdef") || stdout.contains("_wt"),
"{shell}: should contain zsh completion markers"
);
}
"nu" => {
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"
);
}
_ => {}
}
}
}
#[rstest]
fn test_complete_switch_shows_all_remotes_for_ambiguous_branch(mut repo: TestRepo) {
repo.commit("initial");
repo.setup_remote("main");
repo.setup_custom_remote("upstream", "main");
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"]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["branch", "-D", "shared-feature"]);
let output = repo
.completion_cmd_for_shell(&["wt", "switch", ""], "fish")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("shared-feature"),
"Should show shared-feature branch: {stdout}"
);
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");
for i in 0..50 {
repo.run_git(&["branch", &format!("local/branch-{i}")]);
}
for i in 0..60 {
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"]);
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);
assert!(
suggestions.iter().any(|s| s.contains("local/branch-0")),
"Local branches should appear in completions: {stdout}"
);
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");
for i in 0..5 {
repo.run_git(&["branch", &format!("local/branch-{i}")]);
}
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"]);
let output = repo
.completion_cmd_for_shell(&["wt", "switch", ""], "fish")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
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);
assert!(subcommands.contains(&"commit"), "Missing commit");
assert!(subcommands.contains(&"push"), "Missing push");
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);
let commit_count = subcommands.iter().filter(|&&s| s == "commit").count();
assert_eq!(commit_count, 1, "Built-in 'commit' should appear once");
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");
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}");
}
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);
}
#[rstest]
fn test_complete_external_subcommand_listed(repo: TestRepo) {
repo.commit("initial");
let ext_dir = tempfile::tempdir().unwrap();
MockConfig::new("wt-testext")
.command("_default", MockResponse::output("external ran\n"))
.write(ext_dir.path());
let mut cmd = repo.completion_cmd(&["wt", ""]);
prepend_path(&mut cmd, ext_dir.path());
cmd.env("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"),
"External subcommand 'testext' missing from completion output: {stdout}"
);
assert!(
stdout.contains("switch"),
"Built-in 'switch' missing from completion output: {stdout}"
);
}
#[cfg(unix)]
#[rstest]
fn test_complete_external_subcommand_forwards(repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
repo.commit("initial");
let ext_dir = tempfile::tempdir().unwrap();
let script = ext_dir.path().join("wt-testext");
std::fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n%s' '--custom-flag' '--another'\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut cmd = repo.completion_cmd(&["wt", "testext", "--"]);
prepend_path(&mut cmd, ext_dir.path());
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}"
);
}