use crate::common::{
TestRepo, configure_directive_files, directive_files, make_snapshot_cmd,
make_snapshot_cmd_with_global_flags, repo, repo_with_remote, set_temp_home_env,
setup_home_snapshot_settings, setup_snapshot_settings, temp_home, wait_for_file_content,
wt_command,
};
use ansi_str::AnsiStr;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) {
snapshot_switch_impl(test_name, repo, args, false, None, None);
}
fn snapshot_switch_with_directive_file(test_name: &str, repo: &TestRepo, args: &[&str]) {
snapshot_switch_impl(test_name, repo, args, true, None, None);
}
fn snapshot_switch_from_dir(test_name: &str, repo: &TestRepo, args: &[&str], cwd: &Path) {
snapshot_switch_impl(test_name, repo, args, false, Some(cwd), None);
}
#[cfg(not(windows))]
fn snapshot_switch_with_shell(test_name: &str, repo: &TestRepo, args: &[&str], shell: &str) {
snapshot_switch_impl(test_name, repo, args, false, None, Some(shell));
}
fn snapshot_switch_impl(
test_name: &str,
repo: &TestRepo,
args: &[&str],
with_directive_file: bool,
cwd: Option<&Path>,
shell: Option<&str>,
) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let maybe_directive = if with_directive_file {
Some(directive_files())
} else {
None
};
let mut cmd = make_snapshot_cmd(repo, "switch", args, cwd);
if let Some((ref cd_path, ref exec_path, ref _guard)) = maybe_directive {
configure_directive_files(&mut cmd, cd_path, exec_path);
}
if let Some(shell_path) = shell {
cmd.env("SHELL", shell_path);
}
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_switch_create_new_branch(repo: TestRepo) {
snapshot_switch("switch_create_new", &repo, &["--create", "feature-x"]);
}
#[rstest]
fn test_switch_create_shows_progress_when_forced(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "feature-progress"], None);
cmd.env("WORKTRUNK_TEST_DELAYED_STREAM_MS", "0");
assert_cmd_snapshot!("switch_create_with_progress", cmd);
});
}
#[rstest]
fn test_switch_create_existing_branch_error(mut repo: TestRepo) {
repo.add_worktree("feature-y");
snapshot_switch(
"switch_create_existing_error",
&repo,
&["--create", "feature-y"],
);
}
#[rstest]
fn test_switch_create_existing_with_execute(mut repo: TestRepo) {
repo.add_worktree("emails");
snapshot_switch(
"switch_create_existing_with_execute",
&repo,
&[
"--create",
"--execute=claude",
"emails",
"--",
"Check my emails",
],
);
}
#[rstest]
fn test_switch_nonexistent_with_execute(repo: TestRepo) {
snapshot_switch(
"switch_nonexistent_with_execute",
&repo,
&["--execute=claude", "nonexistent", "--", "Check my emails"],
);
}
#[rstest]
fn test_switch_create_with_remote_branch_only(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "remote-feature"]);
repo.run_git(&["push", "origin", "remote-feature"]);
repo.run_git(&["branch", "-D", "remote-feature"]);
snapshot_switch(
"switch_create_remote_only",
&repo,
&["--create", "remote-feature"],
);
}
#[rstest]
fn test_switch_dwim_from_remote(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "dwim-feature"]);
repo.run_git(&["push", "origin", "dwim-feature"]);
repo.run_git(&["branch", "-D", "dwim-feature"]);
snapshot_switch("switch_dwim_from_remote", &repo, &["dwim-feature"]);
}
#[rstest]
fn test_switch_remote_prefix_stripped(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "remote-feature"]);
repo.run_git(&["push", "origin", "remote-feature"]);
repo.run_git(&["branch", "-D", "remote-feature"]);
snapshot_switch(
"switch_remote_prefix_stripped",
&repo,
&["origin/remote-feature"],
);
}
#[rstest]
fn test_switch_remote_prefix_stripped_slash_in_branch(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "username/feature-1"]);
repo.run_git(&["push", "origin", "username/feature-1"]);
repo.run_git(&["branch", "-D", "username/feature-1"]);
snapshot_switch(
"switch_remote_prefix_slash_branch",
&repo,
&["origin/username/feature-1"],
);
}
#[rstest]
fn test_switch_dwim_ambiguous_remotes(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.setup_custom_remote("upstream", "main");
repo.run_git(&["branch", "shared-feature"]);
repo.run_git(&["push", "origin", "shared-feature"]);
repo.run_git(&["push", "upstream", "shared-feature"]);
repo.run_git(&["branch", "-D", "shared-feature"]);
snapshot_switch("switch_dwim_ambiguous_remotes", &repo, &["shared-feature"]);
}
#[rstest]
fn test_switch_create_from_remote_base_no_upstream(#[from(repo_with_remote)] repo: TestRepo) {
let output = repo
.wt_command()
.args(["switch", "--create", "my-feature", "--base=origin/main"])
.output()
.unwrap();
assert!(output.status.success(), "switch should succeed");
let branch_output = repo.git_output(&["branch", "--list", "my-feature"]);
assert!(
branch_output.contains("my-feature"),
"branch should be created"
);
let upstream_check = repo
.git_command()
.args(["rev-parse", "--abbrev-ref", "my-feature@{upstream}"])
.run()
.unwrap();
assert!(
!upstream_check.status.success(),
"branch should NOT have upstream tracking (to prevent accidental push to origin/main)"
);
}
#[rstest]
fn test_switch_existing_local_branch_with_upstream(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "tracked-feature"]);
repo.run_git(&["commit", "--allow-empty", "-m", "feature commit"]);
repo.run_git(&["push", "-u", "origin", "tracked-feature"]);
repo.run_git(&["checkout", "main"]);
snapshot_switch(
"switch_existing_local_with_upstream",
&repo,
&["tracked-feature"],
);
}
#[rstest]
fn test_switch_existing_branch(mut repo: TestRepo) {
repo.add_worktree("feature-z");
snapshot_switch("switch_existing_branch", &repo, &["feature-z"]);
}
#[rstest]
#[cfg(not(windows))]
fn test_switch_existing_with_shell_integration_configured(mut repo: TestRepo) {
use std::fs;
repo.add_worktree("shell-configured");
let zshrc_path = repo.home_path().join(".zshrc");
fs::write(
&zshrc_path,
"# Existing user zsh config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n",
)
.unwrap();
snapshot_switch_with_shell(
"switch_existing_with_shell_configured",
&repo,
&["shell-configured"],
"/bin/zsh",
);
}
#[rstest]
fn test_switch_existing_as_git_subcommand(mut repo: TestRepo) {
repo.add_worktree("git-subcommand-test");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["git-subcommand-test"], None);
cmd.env("GIT_EXEC_PATH", "/usr/lib/git-core");
assert_cmd_snapshot!("switch_as_git_subcommand", cmd);
});
}
#[rstest]
fn test_switch_with_base_branch(repo: TestRepo) {
repo.commit("Initial commit on main");
snapshot_switch(
"switch_with_base",
&repo,
&["--create", "--base", "main", "feature-with-base"],
);
}
#[rstest]
fn test_switch_base_without_create_warning(repo: TestRepo) {
snapshot_switch(
"switch_base_without_create",
&repo,
&["--base", "main", "main"],
);
}
#[rstest]
fn test_switch_create_with_invalid_base(repo: TestRepo) {
snapshot_switch(
"switch_create_invalid_base",
&repo,
&["--create", "new-feature", "--base", "nonexistent-base"],
);
}
#[rstest]
fn test_switch_nonexistent_branch(repo: TestRepo) {
snapshot_switch("switch_nonexistent_branch", &repo, &["nonexistent-branch"]);
}
#[rstest]
fn test_switch_nonexistent_branch_with_fetch_time(repo: TestRepo) {
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("FETCH_HEAD"), "").unwrap();
let mtime = fs::metadata(git_dir.join("FETCH_HEAD"))
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let epoch_3h_later = mtime + 3 * 3600;
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["nonexistent-branch"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", epoch_3h_later.to_string());
assert_cmd_snapshot!("switch_nonexistent_with_fetch_time", cmd);
});
}
#[rstest]
fn test_switch_base_accepts_commitish(repo: TestRepo) {
repo.commit("Initial commit on main");
snapshot_switch(
"switch_base_commitish_head",
&repo,
&["--create", "feature-from-head", "--base", "HEAD"],
);
}
#[rstest]
fn test_switch_internal_mode(repo: TestRepo) {
snapshot_switch_with_directive_file(
"switch_internal_mode",
&repo,
&["--create", "internal-test"],
);
}
#[rstest]
fn test_switch_existing_worktree_internal(mut repo: TestRepo) {
repo.add_worktree("existing-wt");
snapshot_switch_with_directive_file("switch_existing_internal", &repo, &["existing-wt"]);
}
#[rstest]
fn test_switch_internal_with_execute(repo: TestRepo) {
let execute_cmd = "echo 'line1'\necho 'line2'";
snapshot_switch_with_directive_file(
"switch_internal_with_execute",
&repo,
&["--create", "exec-internal", "--execute", execute_cmd],
);
}
#[rstest]
fn test_switch_error_missing_worktree_directory(mut repo: TestRepo) {
let wt_path = repo.add_worktree("missing-wt");
std::fs::remove_dir_all(&wt_path).unwrap();
snapshot_switch("switch_error_missing_directory", &repo, &["missing-wt"]);
}
#[rstest]
fn test_switch_error_path_occupied_by_missing_worktree(mut repo: TestRepo) {
let wt_path = repo.add_worktree("feature/collision");
std::fs::remove_dir_all(&wt_path).unwrap();
snapshot_switch(
"switch_error_path_occupied_missing",
&repo,
&["--create", "feature-collision"],
);
}
#[rstest]
fn test_switch_error_path_occupied(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let expected_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.occupied-branch", repo_name));
std::fs::create_dir_all(&expected_path).unwrap();
std::fs::write(expected_path.join("some_file.txt"), "occupant content").unwrap();
snapshot_switch(
"switch_error_path_occupied",
&repo,
&["--create", "occupied-branch"],
);
std::fs::remove_dir_all(&expected_path).ok();
}
#[rstest]
fn test_switch_execute_success(repo: TestRepo) {
snapshot_switch(
"switch_execute_success",
&repo,
&["--create", "exec-test", "--execute", "echo 'test output'"],
);
}
#[rstest]
fn test_switch_execute_creates_file(repo: TestRepo) {
let create_file_cmd = "echo 'test content' > test.txt";
snapshot_switch(
"switch_execute_creates_file",
&repo,
&["--create", "file-test", "--execute", create_file_cmd],
);
}
#[rstest]
fn test_switch_execute_failure(repo: TestRepo) {
snapshot_switch(
"switch_execute_failure",
&repo,
&["--create", "fail-test", "--execute", "exit 1"],
);
}
#[rstest]
fn test_switch_execute_with_existing_worktree(mut repo: TestRepo) {
repo.add_worktree("existing-exec");
let create_file_cmd = "echo 'existing worktree' > existing.txt";
snapshot_switch(
"switch_execute_existing",
&repo,
&["existing-exec", "--execute", create_file_cmd],
);
}
#[rstest]
fn test_switch_execute_multiline(repo: TestRepo) {
let multiline_cmd = "echo 'line1'\necho 'line2'\necho 'line3'";
snapshot_switch(
"switch_execute_multiline",
&repo,
&["--create", "multiline-test", "--execute", multiline_cmd],
);
}
#[rstest]
fn test_switch_execute_template_branch(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_branch",
&repo,
&[
"--create",
"template-test",
"--execute",
"echo 'branch={{ branch }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_base(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_base",
&repo,
&[
"--create",
"from-main",
"--base",
"main",
"--execute",
"echo 'base={{ base }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_base_without_create(mut repo: TestRepo) {
repo.add_worktree("existing");
snapshot_switch(
"switch_execute_template_base_without_create",
&repo,
&["existing", "--execute", "echo 'base={{ base }}'"],
);
}
#[rstest]
fn test_switch_execute_template_with_filter(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_with_filter",
&repo,
&[
"--create",
"feature/with-slash",
"--execute",
"echo 'sanitized={{ branch | sanitize }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_shell_escape(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_shell_escape",
&repo,
&["--create", "feat;id", "--execute", "echo {{ branch }}"],
);
}
#[rstest]
fn test_switch_execute_template_worktree_path(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_worktree_path",
&repo,
&[
"--create",
"path-test",
"--execute",
"echo 'path={{ worktree_path }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_in_args(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_in_args",
&repo,
&[
"--create",
"args-test",
"--execute",
"echo",
"--",
"branch={{ branch }}",
"repo={{ repo }}",
],
);
}
#[rstest]
fn test_switch_execute_template_error(repo: TestRepo) {
snapshot_switch(
"switch_execute_template_error",
&repo,
&["--create", "error-test", "--execute", "echo {{ unclosed"],
);
}
#[rstest]
fn test_switch_execute_arg_template_error(repo: TestRepo) {
snapshot_switch(
"switch_execute_arg_template_error",
&repo,
&[
"--create",
"arg-error-test",
"--execute",
"echo",
"--",
"valid={{ branch }}",
"invalid={{ unclosed",
],
);
}
#[rstest]
fn test_switch_execute_verbose_template_expansion(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd_with_global_flags(
&repo,
"switch",
&[
"--create",
"verbose-test",
"--execute",
"echo 'branch={{ branch }}'",
],
None,
&["-v"],
);
assert_cmd_snapshot!("switch_execute_verbose_template", cmd);
});
}
#[rstest]
fn test_switch_execute_verbose_multiline_template(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let multiline_template = r#"{% if branch %}
echo 'branch={{ branch }}'
echo 'repo={{ repo }}'
{% endif %}"#;
let mut cmd = make_snapshot_cmd_with_global_flags(
&repo,
"switch",
&[
"--create",
"multiline-test",
"--execute",
multiline_template,
],
None,
&["-v"],
);
assert_cmd_snapshot!("switch_execute_verbose_multiline_template", cmd);
});
}
#[rstest]
fn test_switch_no_config_commands_execute_still_runs(repo: TestRepo) {
snapshot_switch(
"switch_no_hooks_execute_still_runs",
&repo,
&[
"--create",
"no-hooks-test",
"--execute",
"echo 'execute command runs'",
"--no-hooks",
],
);
}
#[rstest]
fn test_switch_no_config_commands_skips_post_start_commands(repo: TestRepo) {
use std::fs;
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
let create_file_cmd = "echo 'marker' > marker.txt";
fs::write(
config_dir.join("wt.toml"),
format!(r#"post-starts = ["{}"]"#, create_file_cmd),
)
.unwrap();
repo.commit("Add config");
let user_config_dir = repo.home_path().join(".config/worktrunk");
fs::create_dir_all(&user_config_dir).unwrap();
fs::write(
user_config_dir.join("config.toml"),
format!(
r#"worktree-path = "../{{{{ repo }}}}.{{{{ branch }}}}"
[projects."main"]
approved-commands = ["{}"]
"#,
create_file_cmd
),
)
.unwrap();
snapshot_switch(
"switch_no_hooks_skips_post_start",
&repo,
&["--create", "no-post-start", "--no-hooks"],
);
}
#[rstest]
fn test_switch_no_config_commands_with_existing_worktree(mut repo: TestRepo) {
repo.add_worktree("existing-no-hooks");
snapshot_switch(
"switch_no_hooks_existing",
&repo,
&[
"existing-no-hooks",
"--execute",
"echo 'execute still runs'",
"--no-hooks",
],
);
}
#[rstest]
fn test_switch_no_config_commands_with_yes(repo: TestRepo) {
use std::fs;
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-starts = ["echo 'test'"]"#,
)
.unwrap();
repo.commit("Add config");
snapshot_switch(
"switch_no_hooks_with_yes",
&repo,
&["--create", "yes-no-hooks", "--yes", "--no-hooks"],
);
}
#[rstest]
fn test_switch_no_verify_deprecated_still_works(repo: TestRepo) {
let output = repo
.wt_command()
.args(["switch", "--create", "deprecated-flag-test", "--no-verify"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--no-verify is deprecated"),
"Expected deprecation warning in stderr: {stderr}"
);
assert!(
stderr.contains("--no-hooks"),
"Expected --no-hooks suggestion in stderr: {stderr}"
);
}
#[rstest]
fn test_switch_create_no_remote(repo: TestRepo) {
snapshot_switch("switch_create_no_remote", &repo, &["--create", "feature"]);
}
#[rstest]
fn test_switch_primary_on_different_branch(mut repo: TestRepo) {
repo.switch_primary_to("develop");
assert_eq!(repo.current_branch(), "develop");
snapshot_switch(
"switch_primary_on_different_branch",
&repo,
&["--create", "feature-from-main"],
);
repo.add_worktree("existing-branch");
snapshot_switch(
"switch_to_existing_primary_on_different_branch",
&repo,
&["existing-branch"],
);
}
#[rstest]
fn test_switch_previous_branch_no_history(repo: TestRepo) {
snapshot_switch("switch_previous_branch_no_history", &repo, &["-"]);
}
#[rstest]
fn test_switch_main_branch(repo: TestRepo) {
repo.run_git(&["branch", "test-feat-x"]);
snapshot_switch("switch_main_branch_to_feature", &repo, &["test-feat-x"]);
snapshot_switch("switch_main_branch", &repo, &["^"]);
}
#[rstest]
fn test_create_with_base_main(repo: TestRepo) {
snapshot_switch(
"create_with_base_main",
&repo,
&["--create", "new-feature", "--base", "^"],
);
}
#[rstest]
fn test_switch_no_warning_when_branch_matches(mut repo: TestRepo) {
repo.add_worktree("feature");
snapshot_switch_with_directive_file(
"switch_no_warning_when_branch_matches",
&repo,
&["feature"],
);
}
#[rstest]
fn test_switch_branch_worktree_mismatch_shows_hint(repo: TestRepo) {
let wrong_path = repo.root_path().parent().unwrap().join("wrong-path");
repo.run_git(&[
"worktree",
"add",
wrong_path.to_str().unwrap(),
"-b",
"feature",
]);
snapshot_switch_with_directive_file(
"switch_branch_worktree_mismatch_shows_hint",
&repo,
&["feature"],
);
}
#[rstest]
fn test_switch_worktree_mismatch_no_shell_integration(repo: TestRepo) {
let wrong_path = repo
.root_path()
.parent()
.unwrap()
.join("wrong-path-no-shell");
repo.run_git(&[
"worktree",
"add",
wrong_path.to_str().unwrap(),
"-b",
"feature-mismatch",
]);
snapshot_switch(
"switch_branch_worktree_mismatch_no_shell",
&repo,
&["feature-mismatch"],
);
}
#[rstest]
fn test_switch_already_at_with_branch_worktree_mismatch(repo: TestRepo) {
let wrong_path = repo
.root_path()
.parent()
.unwrap()
.join("wrong-path-already");
repo.run_git(&[
"worktree",
"add",
wrong_path.to_str().unwrap(),
"-b",
"feature-already",
]);
snapshot_switch_from_dir(
"switch_already_at_branch_worktree_mismatch",
&repo,
&["feature-already"],
&wrong_path,
);
}
#[rstest]
fn test_switch_error_path_occupied_different_branch(repo: TestRepo) {
let feature_path = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
feature_path.to_str().unwrap(),
"-b",
"feature",
]);
repo.run_git_in(&feature_path, &["switch", "-c", "bugfix"]);
snapshot_switch_with_directive_file(
"switch_error_path_occupied_different_branch",
&repo,
&["feature"],
);
}
#[rstest]
fn test_switch_error_path_occupied_detached(repo: TestRepo) {
let feature_path = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
feature_path.to_str().unwrap(),
"-b",
"feature",
]);
let output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&feature_path)
.run()
.unwrap();
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
repo.run_git_in(&feature_path, &["checkout", "--detach", &commit]);
snapshot_switch_with_directive_file("switch_error_path_occupied_detached", &repo, &["feature"]);
}
#[rstest]
fn test_switch_detached_worktree_by_path(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
let worktree_str = worktree_path.to_string_lossy().to_string();
let output = repo
.wt_command()
.args(["switch", &worktree_str])
.output()
.unwrap();
assert!(
output.status.success(),
"wt switch should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[rstest]
fn test_switch_detached_worktree_by_relative_path(mut repo: TestRepo) {
repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
let relative_path = "../repo.feature-detached";
snapshot_switch_with_directive_file(
"switch_detached_worktree_by_relative_path",
&repo,
&[relative_path],
);
}
#[rstest]
fn test_switch_main_worktree_on_different_branch(repo: TestRepo) {
repo.run_git(&["checkout", "-b", "feature"]);
snapshot_switch_with_directive_file(
"switch_main_worktree_on_different_branch",
&repo,
&["main"],
);
}
#[rstest]
fn test_switch_default_branch_from_feature_worktree(mut repo: TestRepo) {
let feature_a_path = repo.add_worktree("feature-a");
repo.run_git(&["checkout", "-b", "feature-rpa"]);
snapshot_switch_from_dir(
"switch_default_branch_from_feature_worktree",
&repo,
&["main"],
&feature_a_path,
);
}
#[rstest]
fn test_switch_internal_execute_exit_code(repo: TestRepo) {
snapshot_switch_with_directive_file(
"switch_internal_execute_exit_code",
&repo,
&["--create", "exit-code-test", "--execute", "exit 42"],
);
}
#[rstest]
fn test_switch_internal_execute_with_output_before_exit(repo: TestRepo) {
let cmd = "echo 'doing work'\nexit 7";
snapshot_switch_with_directive_file(
"switch_internal_execute_output_then_exit",
&repo,
&["--create", "output-exit-test", "--execute", cmd],
);
}
#[rstest]
fn test_switch_previous_with_stale_history(repo: TestRepo) {
for branch in ["branch-a", "branch-b", "branch-c"] {
repo.run_git(&["branch", branch]);
}
snapshot_switch("switch_stale_history_to_a", &repo, &["branch-a"]);
snapshot_switch("switch_stale_history_to_b", &repo, &["branch-b"]);
repo.run_git(&["config", "worktrunk.history", "branch-a"]);
snapshot_switch("switch_stale_history_first_dash", &repo, &["-"]);
snapshot_switch("switch_stale_history_second_dash", &repo, &["-"]);
}
#[rstest]
fn test_switch_ping_pong_realistic(repo: TestRepo) {
repo.run_git(&["branch", "ping-pong"]);
snapshot_switch_from_dir(
"ping_pong_1_main_to_feature",
&repo,
&["ping-pong"],
repo.root_path(),
);
let ping_pong_path = repo.root_path().parent().unwrap().join(format!(
"{}.ping-pong",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
snapshot_switch_from_dir(
"ping_pong_2_feature_to_main",
&repo,
&["main"],
&ping_pong_path,
);
snapshot_switch_from_dir(
"ping_pong_3_dash_to_feature",
&repo,
&["-"],
repo.root_path(),
);
snapshot_switch_from_dir("ping_pong_4_dash_to_main", &repo, &["-"], &ping_pong_path);
snapshot_switch_from_dir(
"ping_pong_5_dash_to_feature_again",
&repo,
&["-"],
repo.root_path(),
);
}
#[cfg(unix)] #[rstest]
fn test_switch_no_args_requires_tty(repo: TestRepo) {
snapshot_switch("switch_missing_argument_hints", &repo, &[]);
}
#[rstest]
fn test_switch_execute_stdin_inheritance(repo: TestRepo) {
use std::io::Write;
use std::process::Stdio;
let test_input = "stdin_inheritance_test_content\n";
let mut cmd = repo.wt_command();
cmd.args(["switch", "--create", "stdin-test", "--execute", "cat"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("failed to spawn wt");
{
let stdin = child.stdin.as_mut().expect("failed to get stdin");
stdin
.write_all(test_input.as_bytes())
.expect("failed to write to stdin");
}
let output = child.wait_with_output().expect("failed to wait for child");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("stdin_inheritance_test_content"),
"Expected cat to receive piped stdin. Got stdout: {}\nstderr: {}",
stdout,
String::from_utf8_lossy(&output.stderr)
);
}
#[rstest]
fn test_switch_outside_git_repo(temp_home: TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
cmd.arg("switch")
.arg("--create")
.arg("feature")
.current_dir(temp_dir.path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_switch_clobber_backs_up_stale_directory(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let expected_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.clobber-dir-test", repo_name));
std::fs::create_dir_all(&expected_path).unwrap();
std::fs::write(expected_path.join("stale_file.txt"), "stale content").unwrap();
snapshot_switch(
"switch_clobber_removes_stale_dir",
&repo,
&["--create", "--clobber", "clobber-dir-test"],
);
assert!(expected_path.exists());
assert!(expected_path.is_dir());
let backup_path = repo.root_path().parent().unwrap().join(format!(
"{}.clobber-dir-test.bak.20250102-000000",
repo_name
));
assert!(
backup_path.exists(),
"Backup should exist at {:?}",
backup_path
);
assert!(backup_path.is_dir());
let stale_file = backup_path.join("stale_file.txt");
assert!(stale_file.exists(), "Stale file should be in backup");
assert_eq!(
std::fs::read_to_string(&stale_file).unwrap(),
"stale content"
);
}
#[rstest]
fn test_switch_clobber_backs_up_stale_file(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let expected_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.clobber-file-test", repo_name));
std::fs::write(&expected_path, "stale file content").unwrap();
snapshot_switch(
"switch_clobber_removes_stale_file",
&repo,
&["--create", "--clobber", "clobber-file-test"],
);
assert!(expected_path.is_dir());
let backup_path = repo.root_path().parent().unwrap().join(format!(
"{}.clobber-file-test.bak.20250102-000000",
repo_name
));
assert!(
backup_path.exists(),
"Backup should exist at {:?}",
backup_path
);
assert!(backup_path.is_file());
assert_eq!(
std::fs::read_to_string(&backup_path).unwrap(),
"stale file content"
);
}
#[rstest]
fn test_switch_clobber_error_backup_exists(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let expected_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.clobber-backup-exists", repo_name));
std::fs::create_dir_all(&expected_path).unwrap();
let backup_path = repo.root_path().parent().unwrap().join(format!(
"{}.clobber-backup-exists.bak.20250102-000000",
repo_name
));
std::fs::create_dir_all(&backup_path).unwrap();
snapshot_switch(
"switch_clobber_error_backup_exists",
&repo,
&["--create", "--clobber", "clobber-backup-exists"],
);
assert!(expected_path.exists());
assert!(backup_path.exists());
}
#[rstest]
fn test_switch_post_hook_shows_path_without_shell_integration(repo: TestRepo) {
use std::fs;
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
"post-switch = \"echo switched\"\n",
)
.unwrap();
repo.commit("Add config");
snapshot_switch(
"switch_post_hook_path_annotation",
&repo,
&["--create", "post-hook-test", "--yes"],
);
}
#[rstest]
fn test_switch_post_hook_no_path_with_shell_integration(repo: TestRepo) {
use std::fs;
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
"post-switch = \"echo switched\"\n",
)
.unwrap();
repo.commit("Add config");
snapshot_switch_with_directive_file(
"switch_post_hook_no_path_with_shell_integration",
&repo,
&["--create", "post-hook-shell-test", "--yes"],
);
}
#[rstest]
fn test_switch_combined_post_switch_and_post_start_hooks(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-switch = "echo switched"
post-start = "echo started"
"#,
)
.unwrap();
repo.commit("Add config");
snapshot_switch(
"switch_combined_hooks",
&repo,
&["--create", "combined-hooks-test", "--yes"],
);
}
#[rstest]
fn test_switch_clobber_path_with_extension(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let expected_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.clobber-ext.txt", repo_name));
std::fs::write(&expected_path, "file with extension").unwrap();
snapshot_switch(
"switch_clobber_path_with_extension",
&repo,
&["--create", "--clobber", "clobber-ext.txt"],
);
assert!(expected_path.is_dir());
let backup_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.clobber-ext.txt.bak.20250102-000000", repo_name));
assert!(
backup_path.exists(),
"Backup should exist at {:?}",
backup_path
);
assert_eq!(
std::fs::read_to_string(&backup_path).unwrap(),
"file with extension"
);
}
#[rstest]
fn test_switch_create_no_hint_with_custom_worktree_path(repo: TestRepo) {
repo.write_test_config(r#"worktree-path = ".worktrees/{{ branch | sanitize }}""#);
let output = repo
.wt_command()
.args(["switch", "--create", "test-no-hint"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Customize worktree locations"),
"Hint should be suppressed when user has custom worktree-path config"
);
}
#[rstest]
fn test_switch_create_no_hint_with_project_specific_worktree_path(repo: TestRepo) {
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-org/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/test-org/test-repo.git",
]);
repo.write_test_config(
r#"
[projects."github.com/test-org/test-repo"]
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
"#,
);
let output = repo
.wt_command()
.args(["switch", "--create", "project-feature"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"switch --create should succeed, stderr: {stderr}"
);
assert!(
!stderr.contains("customize worktree locations"),
"Hint should be suppressed when project has custom worktree-path. stderr: {stderr}"
);
}
use crate::common::mock_commands::{MockConfig, MockResponse, copy_mock_binary};
fn set_github_remote_url(repo: &TestRepo) {
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
}
fn setup_mock_gh_for_pr(repo: &TestRepo, gh_response: Option<&str>) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
if let Some(response) = gh_response {
fs::write(mock_bin.join("pr_response.json"), response).unwrap();
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("api", MockResponse::file("pr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
}
mock_bin
}
fn configure_mock_gh_env(cmd: &mut std::process::Command, mock_bin: &Path) {
cmd.env("MOCK_CONFIG_DIR", mock_bin);
let (path_var_name, current_path) = 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_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.to_path_buf());
let new_path = std::env::join_paths(&paths)
.unwrap()
.to_string_lossy()
.into_owned();
cmd.env(path_var_name, new_path);
}
#[rstest]
fn test_switch_pr_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_create_conflict", cmd);
});
}
#[rstest]
fn test_switch_pr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_same_repo", cmd);
});
}
#[rstest]
fn test_switch_pr_same_repo_limited_refspec(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
"remote.origin.fetch",
"+refs/heads/main:refs/remotes/origin/main",
]);
let gh_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_same_repo_limited_refspec", cmd);
});
}
#[rstest]
fn test_switch_pr_same_repo_no_remote(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
let gh_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_same_repo_no_remote", cmd);
});
}
#[rstest]
fn test_switch_pr_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork", cmd);
});
}
#[rstest]
fn test_switch_pr_hooks_see_pr_vars(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
repo.write_project_config(
r#"pre-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/pre_start.txt"
post-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_start.txt"
post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_switch.txt"
"#,
);
let mut cmd = repo.wt_command();
cmd.args(["switch", "pr:42", "--yes"]);
configure_mock_gh_env(&mut cmd, &mock_bin);
let output = cmd.output().expect("wt switch pr:42 should run");
assert!(
output.status.success(),
"wt switch pr:42 failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let expected = "pr_number=42 pr_url=https://github.com/owner/test-repo/pull/42";
for marker in ["pre_start.txt", "post_start.txt", "post_switch.txt"] {
let path = repo.root_path().join(marker);
wait_for_file_content(&path);
let contents = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{marker} should have been written: {e}"));
assert_eq!(
contents.trim(),
expected,
"{marker} hook should see canonical pr_number and pr_url variables",
);
}
}
#[rstest]
fn test_switch_pr_fork_no_upstream_remote(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_no_upstream", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_gh_default_repo(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
repo.run_git(&[
"remote",
"add",
"upstream",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/contributor/test-repo.git",
]);
repo.run_git(&[
"config",
"--add",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("pr_response.json"), gh_response).unwrap();
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"repo set-default --view",
MockResponse::output("owner/test-repo\n"),
)
.command("api", MockResponse::file("pr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_gh_default", cmd);
});
}
#[rstest]
fn test_switch_pr_not_found(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"Not Found","status":"404"}"#)
.with_stderr("gh: Not Found (HTTP 404)")
.with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:9999"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_not_found", cmd);
});
}
#[rstest]
fn test_switch_pr_deleted_fork(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": null
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_deleted_fork", cmd);
});
}
#[rstest]
fn test_switch_pr_base_conflict(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--base", "main", "pr:101"], None);
assert_cmd_snapshot!("switch_pr_base_conflict", cmd);
});
}
#[rstest]
fn test_switch_base_pr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&[
"--create",
"feat/visual-tweaks",
"--base",
"pr:101",
"--no-cd",
],
None,
);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_base_pr_same_repo", cmd);
});
}
#[rstest]
fn test_switch_base_pr_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let mut settings = setup_snapshot_settings(&repo);
settings.add_filter(r"[0-9a-f]{40}", "[SHA]");
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["--create", "my-work", "--base", "pr:42", "--no-cd"],
None,
);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_base_pr_fork", cmd);
});
let branches = String::from_utf8_lossy(
&repo
.git_command()
.args(["branch", "--list"])
.run()
.unwrap()
.stdout,
)
.into_owned();
assert!(
!branches.contains("feature-fix"),
"fork PR head should not produce a local tracking branch: {branches}"
);
assert!(
!branches.contains("contributor/feature-fix"),
"prefixed fork PR branch should not be created: {branches}"
);
}
#[rstest]
fn test_switch_base_mr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
let glab_response = r#"{
"title": "Fix authentication bug in login flow",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature-auth",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["--create", "feat/follow-up", "--base", "mr:101", "--no-cd"],
None,
);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_base_mr_same_repo", cmd);
});
}
#[rstest]
fn test_switch_base_pr_without_create(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("existing");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["existing", "--base", "pr:101", "--no-cd"],
None,
);
assert_cmd_snapshot!("switch_base_pr_without_create", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_existing_same_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let branch_name = "feature-fix";
repo.run_git(&["branch", branch_name, "main"]);
repo.run_git(&[
"config",
&format!("branch.{}.remote", branch_name),
"origin",
]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", branch_name),
"refs/pull/42/head",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_existing_same_pr", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_existing_different_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let branch_name = "feature-fix";
repo.run_git(&["branch", branch_name, "main"]);
repo.run_git(&[
"config",
&format!("branch.{}.remote", branch_name),
"origin",
]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", branch_name),
"refs/pull/99/head", ]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_existing_different_pr", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_existing_same_pr_wrong_remote(#[from(repo_with_remote)] mut repo: TestRepo) {
set_github_remote_url(&repo);
repo.setup_custom_remote("fork", "main");
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{sha}:refs/pull/42/head")]);
repo.run_git(&["checkout", "main"]);
let branch_name = "feature-fix";
repo.run_git(&["branch", branch_name, "main"]);
repo.run_git(&["config", &format!("branch.{branch_name}.remote"), "fork"]);
repo.run_git(&[
"config",
&format!("branch.{branch_name}.merge"),
"refs/pull/42/head",
]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{bare_url}.insteadOf"),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(output.status.success(), "switch should succeed");
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("Using prefixed branch name contributor/feature-fix due to name conflict"),
"expected prefixed branch warning when existing branch tracks the PR on the wrong remote\nstderr:\n{stderr}",
);
}
#[rstest]
fn test_switch_pr_fork_existing_no_tracking(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
repo.run_git(&["checkout", "-b", "pr-source"]);
fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap();
repo.run_git(&["add", "pr-file.txt"]);
repo.run_git(&["commit", "-m", "PR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
repo.run_git(&["checkout", "main"]);
let branch_name = "feature-fix";
repo.run_git(&["branch", branch_name, "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_existing_no_tracking", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_prefixed_exists_same_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
repo.run_git(&["branch", "feature-fix", "main"]);
let prefixed_branch = "contributor/feature-fix";
repo.run_git(&["branch", prefixed_branch, "main"]);
repo.run_git(&[
"config",
&format!("branch.{}.remote", prefixed_branch),
"origin",
]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", prefixed_branch),
"refs/pull/42/head", ]);
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.contributor-feature-fix");
repo.run_git(&[
"worktree",
"add",
worktree_path.to_str().unwrap(),
prefixed_branch,
]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_prefixed_exists_same_pr", cmd);
});
}
#[rstest]
fn test_switch_pr_fork_prefixed_exists_different_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
repo.run_git(&["branch", "feature-fix", "main"]);
let prefixed_branch = "contributor/feature-fix";
repo.run_git(&["branch", prefixed_branch, "main"]);
repo.run_git(&[
"config",
&format!("branch.{}.remote", prefixed_branch),
"origin",
]);
repo.run_git(&[
"config",
&format!("branch.{}.merge", prefixed_branch),
"refs/pull/99/head", ]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/42"
}"#;
let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_fork_prefixed_exists_different_pr", cmd);
});
}
#[rstest]
fn test_switch_pr_not_authenticated(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"Requires authentication","status":"401"}"#)
.with_stderr("gh: Requires authentication (HTTP 401)")
.with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_not_authenticated", cmd);
});
}
#[rstest]
fn test_switch_pr_rate_limit(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"api",
MockResponse::output(
r#"{"message":"API rate limit exceeded for user","status":"403"}"#,
)
.with_stderr("gh: API rate limit exceeded (HTTP 403)")
.with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_rate_limit", cmd);
});
}
#[rstest]
fn test_switch_pr_invalid_json(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("api", MockResponse::output("not valid json {{{"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_invalid_json", cmd);
});
}
#[rstest]
fn test_switch_pr_network_error(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"api",
MockResponse::stderr("connection refused: network is unreachable").with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_network_error", cmd);
});
}
#[rstest]
fn test_switch_pr_unknown_error(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
let error_message = "error: unexpected API response\n\
code: 500\n\
message: Internal server error";
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("api", MockResponse::stderr(error_message).with_exit_code(1))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_unknown_error", cmd);
});
}
#[rstest]
fn test_switch_pr_empty_branch(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "gh");
let gh_response = r#"{
"title": "PR with empty branch",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"ref": "",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://github.com/owner/test-repo/pull/101"
}"#;
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command("api", MockResponse::output(gh_response))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_gh_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_empty_branch", cmd);
});
}
fn setup_mock_glab_for_mr(repo: &TestRepo, glab_response: Option<&str>) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
if let Some(response) = glab_response {
fs::write(mock_bin.join("mr_response.json"), response).unwrap();
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command("api", MockResponse::file("mr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
}
mock_bin
}
fn configure_mock_glab_env(cmd: &mut std::process::Command, mock_bin: &Path) {
cmd.env("MOCK_CONFIG_DIR", mock_bin);
let (path_var_name, current_path) = 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_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, mock_bin.to_path_buf());
let new_path = std::env::join_paths(&paths)
.unwrap()
.to_string_lossy()
.into_owned();
cmd.env(path_var_name, new_path);
}
#[rstest]
fn test_switch_mr_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
let glab_response = r#"{
"title": "Fix authentication bug in login flow",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature-auth",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_create_conflict", cmd);
});
}
#[rstest]
fn test_switch_mr_base_conflict(repo: TestRepo) {
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--base", "main", "mr:101"], None);
assert_cmd_snapshot!("switch_mr_base_conflict", cmd);
});
}
#[rstest]
fn test_switch_mr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
let glab_response = r#"{
"title": "Fix authentication bug in login flow",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature-auth",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo", cmd);
});
}
#[rstest]
fn test_switch_mr_same_repo_limited_refspec(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
"remote.origin.fetch",
"+refs/heads/main:refs/remotes/origin/main",
]);
let glab_response = r#"{
"title": "Fix authentication bug in login flow",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature-auth",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo_limited_refspec", cmd);
});
}
#[rstest]
fn test_switch_mr_same_repo_no_remote(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/contributor/test-repo.git",
]);
let glab_response = r#"{
"title": "Fix authentication bug in login flow",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature-auth",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo_no_remote", cmd);
});
}
#[rstest]
fn test_switch_mr_malformed_web_url_no_separator(#[from(repo_with_remote)] repo: TestRepo) {
let glab_response = r#"{
"title": "Fix bug",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_malformed_web_url", cmd);
});
}
#[rstest]
fn test_switch_mr_malformed_web_url_no_project(#[from(repo_with_remote)] repo: TestRepo) {
let glab_response = r#"{
"title": "Fix bug",
"author": {"username": "alice"},
"state": "opened",
"draft": false,
"source_branch": "feature",
"source_project_id": 123,
"target_project_id": 123,
"web_url": "https://gitlab.com/-/merge_requests/101"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_malformed_web_url_no_project", cmd);
});
}
#[rstest]
fn test_switch_mr_not_found(#[from(repo_with_remote)] repo: TestRepo) {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"404 Not found"}"#).with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:9999"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_not_found", cmd);
});
}
#[rstest]
fn test_switch_mr_not_authenticated(#[from(repo_with_remote)] repo: TestRepo) {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"401 Unauthorized"}"#).with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_not_authenticated", cmd);
});
}
#[rstest]
fn test_switch_mr_invalid_json(#[from(repo_with_remote)] repo: TestRepo) {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command("api", MockResponse::output("not valid json {{{"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_invalid_json", cmd);
});
}
#[rstest]
fn test_switch_mr_empty_branch(#[from(repo_with_remote)] repo: TestRepo) {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
let glab_response = r#"{
"title": "MR with empty branch",
"author": {"username": "contributor"},
"state": "opened",
"draft": false,
"source_branch": "",
"source_project_id": 456,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/101"
}"#;
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command("api", MockResponse::output(glab_response))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_empty_branch", cmd);
});
}
#[rstest]
fn test_switch_mr_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "mr-source"]);
fs::write(repo.root_path().join("mr-file.txt"), "MR content").unwrap();
repo.run_git(&["add", "mr-file.txt"]);
repo.run_git(&["commit", "-m", "MR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&[
"push",
"origin",
&format!("{}:refs/merge-requests/42/head", sha),
]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
let mr_response = r#"{
"title": "Add feature fix for edge case",
"author": {"username": "contributor"},
"state": "opened",
"draft": false,
"source_branch": "feature-fix",
"source_project_id": 456,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/42"
}"#;
let source_project_response = r#"{
"ssh_url_to_repo": "git@gitlab.com:contributor/test-repo.git",
"http_url_to_repo": "https://gitlab.com/contributor/test-repo.git"
}"#;
let target_project_response = r#"{
"ssh_url_to_repo": "git@gitlab.com:owner/test-repo.git",
"http_url_to_repo": "https://gitlab.com/owner/test-repo.git"
}"#;
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api projects/:id/merge_requests/42",
MockResponse::output(mr_response),
)
.command(
"api projects/456",
MockResponse::output(source_project_response),
)
.command(
"api projects/123",
MockResponse::output(target_project_response),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork", cmd);
});
}
#[rstest]
fn test_switch_mr_fork_existing_branch_tracks_mr(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "feature-fix"]);
fs::write(repo.root_path().join("mr-file.txt"), "MR content").unwrap();
repo.run_git(&["add", "mr-file.txt"]);
repo.run_git(&["commit", "-m", "MR commit"]);
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
repo.run_git(&[
"push",
"origin",
&format!("{}:refs/merge-requests/42/head", sha),
]);
repo.run_git(&["config", "branch.feature-fix.remote", "origin"]);
repo.run_git(&[
"config",
"branch.feature-fix.merge",
"refs/merge-requests/42/head",
]);
repo.run_git(&["checkout", "main"]);
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
let glab_response = r#"{
"title": "Add feature fix for edge case",
"author": {"username": "contributor"},
"state": "opened",
"draft": false,
"source_branch": "feature-fix",
"source_project_id": 456,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/42"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_branch_tracks_mr", cmd);
});
}
#[rstest]
fn test_switch_mr_fork_existing_branch_tracks_different(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "feature-fix"]);
fs::write(repo.root_path().join("mr-file.txt"), "MR content").unwrap();
repo.run_git(&["add", "mr-file.txt"]);
repo.run_git(&["commit", "-m", "MR commit"]);
repo.run_git(&["config", "branch.feature-fix.remote", "origin"]);
repo.run_git(&[
"config",
"branch.feature-fix.merge",
"refs/merge-requests/99/head", ]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
let glab_response = r#"{
"title": "Add feature fix for edge case",
"author": {"username": "contributor"},
"state": "opened",
"draft": false,
"source_branch": "feature-fix",
"source_project_id": 456,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/42"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_branch_tracks_different", cmd);
});
}
#[rstest]
fn test_switch_mr_fork_existing_no_tracking(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "feature-fix", "main"]);
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
let glab_response = r#"{
"title": "Add feature fix for edge case",
"author": {"username": "contributor"},
"state": "opened",
"draft": false,
"source_branch": "feature-fix",
"source_project_id": 456,
"target_project_id": 123,
"web_url": "https://gitlab.com/owner/test-repo/-/merge_requests/42"
}"#;
let mock_bin = setup_mock_glab_for_mr(&repo, Some(glab_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_no_tracking", cmd);
});
}
#[rstest]
fn test_switch_mr_unknown_error(#[from(repo_with_remote)] repo: TestRepo) {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "glab");
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api",
MockResponse::stderr("glab: unexpected internal error: something went wrong")
.with_exit_code(1),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_glab_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_unknown_error", cmd);
});
}
#[cfg(unix)]
fn setup_minimal_bin_without_cli(repo: &TestRepo) -> Option<std::path::PathBuf> {
let minimal_bin = repo.root_path().join("minimal-bin");
fs::create_dir_all(&minimal_bin).unwrap();
let git_path = which::which("git").expect("git must be installed to run tests");
std::os::unix::fs::symlink(&git_path, minimal_bin.join("git")).unwrap();
Some(minimal_bin)
}
#[cfg(windows)]
fn setup_minimal_bin_without_cli(_repo: &TestRepo) -> Option<std::path::PathBuf> {
None
}
fn configure_cli_not_installed_env(cmd: &mut std::process::Command, minimal_bin: &Path) {
cmd.env("PATH", minimal_bin);
}
#[rstest]
fn test_switch_pr_gh_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
let Some(minimal_bin) = setup_minimal_bin_without_cli(&repo) else {
eprintln!("Skipping test: symlinks not available on this system");
return;
};
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_cli_not_installed_env(&mut cmd, &minimal_bin);
assert_cmd_snapshot!("switch_pr_gh_not_installed", cmd);
});
}
#[rstest]
fn test_switch_mr_glab_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
let Some(minimal_bin) = setup_minimal_bin_without_cli(&repo) else {
eprintln!("Skipping test: symlinks not available on this system");
return;
};
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_cli_not_installed_env(&mut cmd, &minimal_bin);
assert_cmd_snapshot!("switch_mr_glab_not_installed", cmd);
});
}
#[rstest]
fn test_switch_already_at_preserves_history(repo: TestRepo) {
repo.run_git(&["branch", "hist-feature"]);
let feature_path = repo.root_path().parent().unwrap().join(format!(
"{}.hist-feature",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
snapshot_switch_from_dir(
"already_at_preserves_history_1_to_feature",
&repo,
&["hist-feature"],
repo.root_path(),
);
snapshot_switch_from_dir(
"already_at_preserves_history_2_noop",
&repo,
&["hist-feature"],
&feature_path,
);
snapshot_switch_from_dir(
"already_at_preserves_history_3_dash_to_main",
&repo,
&["-"],
&feature_path,
);
}
#[rstest]
fn test_switch_first_output_exits_cleanly(mut repo: TestRepo) {
repo.add_worktree("feature-bench");
let output = repo
.wt_command()
.args(["switch", "feature-bench", "--yes"])
.env("WORKTRUNK_FIRST_OUTPUT", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"WORKTRUNK_FIRST_OUTPUT should exit 0: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
#[rstest]
fn test_switch_base_without_create_warns_not_errors(repo: TestRepo) {
repo.run_git(&["branch", "base-test"]);
snapshot_switch(
"switch_base_without_create_warns",
&repo,
&["base-test", "--base", "-"],
);
}
#[rstest]
fn test_switch_cd_flag_overrides_no_cd_config(repo: TestRepo) {
repo.write_test_config(
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[switch]
cd = false
"#,
);
repo.run_git(&["branch", "cd-override-test"]);
snapshot_switch(
"switch_cd_flag_overrides_config",
&repo,
&["cd-override-test", "--cd"],
);
}
#[rstest]
fn test_switch_no_cd_flag_explicit(repo: TestRepo) {
repo.run_git(&["branch", "no-cd-explicit"]);
snapshot_switch(
"switch_no_cd_flag_explicit",
&repo,
&["no-cd-explicit", "--no-cd"],
);
}
#[rstest]
fn test_switch_with_relative_worktree_paths(repo: TestRepo) {
repo.run_git(&["config", "worktree.useRelativePaths", "true"]);
snapshot_switch(
"switch_create_relative_paths",
&repo,
&["--create", "relative-test"],
);
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.relative-test");
let git_file = std::fs::read_to_string(worktree_path.join(".git")).unwrap();
assert!(
!git_file.contains(repo.root_path().to_str().unwrap()),
"Expected relative path in .git file, but found absolute path: {git_file}"
);
assert!(
git_file.contains("gitdir: ../"),
"Expected relative gitdir in .git file: {git_file}"
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "list", &[], None);
assert_cmd_snapshot!("list_with_relative_paths", cmd);
});
snapshot_switch("switch_to_relative_paths", &repo, &["relative-test"]);
}
#[rstest]
fn test_switch_format_json_create(repo: TestRepo) {
let output = repo
.wt_command()
.args([
"switch",
"--create",
"json-test",
"--no-cd",
"--yes",
"--format=json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["action"], "created");
assert_eq!(json["branch"], "json-test");
assert!(json["path"].as_str().unwrap().contains("json-test"));
assert_eq!(json["created_branch"], true);
}
#[rstest]
fn test_switch_format_json_existing(mut repo: TestRepo) {
repo.add_worktree("existing-json");
let output = repo
.wt_command()
.args([
"switch",
"existing-json",
"--no-cd",
"--yes",
"--format=json",
])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["action"], "existing");
assert_eq!(json["branch"], "existing-json");
assert!(json.get("created_branch").is_none());
}
#[rstest]
fn test_switch_format_json_already_at(mut repo: TestRepo) {
let path = repo.add_worktree("already-json");
let output = repo
.wt_command()
.current_dir(&path)
.args([
"switch",
"already-json",
"--no-cd",
"--yes",
"--format=json",
])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["action"], "already_at");
assert_eq!(json["branch"], "already-json");
}
#[rstest]
fn test_switch_format_table_rejected_by_clap(repo: TestRepo) {
let output = repo
.wt_command()
.args([
"switch",
"--create",
"table-test",
"--no-cd",
"--yes",
"--format=table",
])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("invalid value"), "stderr: {stderr}");
}
#[rstest]
fn test_switch_no_cd_config_default(repo: TestRepo) {
repo.write_test_config(
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[switch]
cd = false
"#,
);
repo.run_git(&["branch", "no-cd-config-test"]);
snapshot_switch("switch_no_cd_config_default", &repo, &["no-cd-config-test"]);
}