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_with_remote_only_base(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "releases/4.x.x"]);
repo.run_git(&["push", "origin", "releases/4.x.x"]);
repo.run_git(&["branch", "-D", "releases/4.x.x"]);
let output = repo
.wt_command()
.args(["switch", "--create", "new-wt", "--base", "releases/4.x.x"])
.output()
.unwrap();
assert!(
output.status.success(),
"switch should succeed; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let branch_output = repo.git_output(&["branch", "--list", "new-wt"]);
assert!(branch_output.contains("new-wt"), "branch should be created");
let upstream_check = repo
.git_command()
.args(["rev-parse", "--abbrev-ref", "new-wt@{upstream}"])
.run()
.unwrap();
assert!(
!upstream_check.status.success(),
"new branch should NOT track origin/releases/4.x.x"
);
}
#[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-start = "{}""#, 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"],
);
std::thread::sleep(std::time::Duration::from_millis(500));
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let worktree = repo
.root_path()
.parent()
.unwrap()
.join(format!("{repo_name}.no-post-start"));
assert!(
!worktree.join("marker.txt").exists(),
"post-start hook should have been skipped, but marker.txt was created"
);
}
#[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-start = "echo 'marker' > marker.txt""#,
)
.unwrap();
repo.commit("Add config");
snapshot_switch(
"switch_no_hooks_with_yes",
&repo,
&["--create", "yes-no-hooks", "--yes", "--no-hooks"],
);
std::thread::sleep(std::time::Duration::from_millis(500));
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let worktree = repo
.root_path()
.parent()
.unwrap()
.join(format!("{repo_name}.yes-no-hooks"));
assert!(
!worktree.join("marker.txt").exists(),
"post-start hook should have been skipped, but marker.txt was created"
);
}
#[rstest]
fn test_switch_create_reads_base_branch_config(mut repo: TestRepo) {
let other_wt = repo.add_worktree("other-base");
fs::create_dir_all(other_wt.join(".config")).unwrap();
fs::write(
other_wt.join(".config/wt.toml"),
r#"pre-start = "echo pre-start-from-base > {{ repo_path }}/pre-start-marker.txt"
post-start = "echo post-start-from-base > {{ repo_path }}/post-start-marker.txt"
"#,
)
.unwrap();
repo.run_git_in(&other_wt, &["add", ".config/wt.toml"]);
repo.run_git_in(&other_wt, &["commit", "-m", "Add hooks on other-base"]);
let output = repo
.wt_command()
.args([
"switch",
"--create",
"new-feature",
"--base",
"other-base",
"--yes",
])
.output()
.unwrap();
assert!(
output.status.success(),
"wt switch --create failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let pre_marker = repo.root_path().join("pre-start-marker.txt");
wait_for_file_content(&pre_marker);
assert_eq!(
fs::read_to_string(&pre_marker).unwrap().trim(),
"pre-start-from-base",
"pre-start should run with the base branch's config"
);
let post_marker = repo.root_path().join("post-start-marker.txt");
wait_for_file_content(&post_marker);
assert_eq!(
fs::read_to_string(&post_marker).unwrap().trim(),
"post-start-from-base",
"post-start should run with the base branch's config"
);
}
#[rstest]
fn test_switch_existing_reads_destination_worktree_config(mut repo: TestRepo) {
let dest = repo.add_worktree("dest");
fs::create_dir_all(dest.join(".config")).unwrap();
fs::write(
dest.join(".config/wt.toml"),
r#"post-switch = "echo post-switch-from-dest > {{ repo_path }}/post-switch-marker.txt""#,
)
.unwrap();
let output = repo
.wt_command()
.args(["switch", "dest", "--yes"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt switch dest failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("post-switch-marker.txt");
wait_for_file_content(&marker);
assert_eq!(
fs::read_to_string(&marker).unwrap().trim(),
"post-switch-from-dest",
"post-switch should run with the destination worktree's config"
);
}
#[rstest]
fn test_switch_existing_aborts_on_malformed_destination_config(mut repo: TestRepo) {
let dest = repo.add_worktree("dest");
fs::create_dir_all(dest.join(".config")).unwrap();
fs::write(dest.join(".config/wt.toml"), "this is not [ valid toml").unwrap();
let output = repo
.wt_command()
.args(["switch", "dest", "--yes"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"wt switch should abort on a malformed destination config; stderr:\n{stderr}"
);
assert!(
stderr.contains("wt.toml"),
"error should name the offending config file; stderr:\n{stderr}"
);
}
#[rstest]
fn test_switch_create_honors_project_config_path_override(mut repo: TestRepo) {
let other_wt = repo.add_worktree("other-base");
fs::create_dir_all(other_wt.join(".config")).unwrap();
fs::write(
other_wt.join(".config/wt.toml"),
r#"post-start = "echo IGNORED-COMMITTED-HOOK > {{ repo_path }}/wrong-marker.txt""#,
)
.unwrap();
repo.run_git_in(&other_wt, &["add", ".config/wt.toml"]);
repo.run_git_in(&other_wt, &["commit", "-m", "Committed hook on other-base"]);
let override_path = repo.root_path().parent().unwrap().join("override-wt.toml");
fs::write(
&override_path,
r#"post-start = "echo OVERRIDE-HOOK > {{ repo_path }}/right-marker.txt""#,
)
.unwrap();
let output = repo
.wt_command()
.env("WORKTRUNK_PROJECT_CONFIG_PATH", &override_path)
.args([
"switch",
"--create",
"new-feature",
"--base",
"other-base",
"--yes",
])
.output()
.unwrap();
assert!(
output.status.success(),
"wt switch --create failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let right = repo.root_path().join("right-marker.txt");
wait_for_file_content(&right);
assert_eq!(fs::read_to_string(&right).unwrap().trim(), "OVERRIDE-HOOK");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
!repo.root_path().join("wrong-marker.txt").exists(),
"the base ref's committed hook must not run when the config path is overridden"
);
}
#[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)
);
}
#[cfg(unix)]
#[rstest]
fn test_switch_worktree_by_symlinked_path(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-symlinked");
let symlink_path = worktree_path.parent().unwrap().join("worktree-link");
std::os::unix::fs::symlink(&worktree_path, &symlink_path).unwrap();
let symlink_str = symlink_path.to_string_lossy().to_string();
let output = repo
.wt_command()
.args(["switch", &symlink_str])
.output()
.unwrap();
assert!(
output.status.success(),
"wt switch via symlink 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_uses_codename_in_worktree_path(repo: TestRepo) {
repo.write_test_config(
r#"worktree-path = "{{ repo_path }}/../{{ repo }}.{{ branch | codename(3) }}""#,
);
let output = repo
.wt_command()
.args([
"switch",
"--create",
"feature/JIRA-1234",
"--format=json",
"--no-cd",
])
.output()
.unwrap();
assert!(
output.status.success(),
"switch --create should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let path = Path::new(json["path"].as_str().unwrap());
let dir_name = path.file_name().unwrap().to_str().unwrap();
let repo_prefix = format!(
"{}.",
repo.root_path().file_name().unwrap().to_str().unwrap()
);
let codename = dir_name.strip_prefix(&repo_prefix).unwrap();
assert!(path.exists(), "worktree should exist @ {}", path.display());
assert_eq!(codename.split('-').count(), 3, "got: {codename}");
assert!(
codename.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"got: {codename}"
);
}
#[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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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();
fs::create_dir_all(repo.root_path().join(".config")).unwrap();
fs::write(
repo.root_path().join(".config/wt.toml"),
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"
"#,
)
.unwrap();
repo.run_git(&["add", "pr-file.txt", ".config/wt.toml"]);
repo.run_git(&["commit", "-m", "PR commit with hook config"]);
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 cmd = repo.wt_command();
cmd.args(["switch", "pr:42", "--yes"]);
configure_mock_cli_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_reads_pr_ref_config(#[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();
fs::create_dir_all(repo.root_path().join(".config")).unwrap();
fs::write(
repo.root_path().join(".config/wt.toml"),
r#"post-start = "echo PR-CONFIG-HOOK-RAN > {{ repo_path }}/pr-hook-marker.txt""#,
)
.unwrap();
repo.run_git(&["add", "pr-file.txt", ".config/wt.toml"]);
repo.run_git(&["commit", "-m", "PR commit with hook config"]);
let sha = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&["push", "origin", &format!("{sha}:refs/pull/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://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 = repo.wt_command();
cmd.args(["switch", "pr:42"]);
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().expect("wt switch pr:42 should run");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("PR-CONFIG-HOOK-RAN"),
"approval prompt should list the PR ref's post-start hook; stderr:\n{stderr}"
);
assert!(
!output.status.success(),
"non-interactive approval should bail without running the hook"
);
assert!(
!repo.root_path().join("pr-hook-marker.txt").exists(),
"a declined approval must not run the hook"
);
let mut cmd = repo.wt_command();
cmd.args(["switch", "pr:42", "--yes"]);
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().expect("wt switch pr:42 --yes should run");
assert!(
output.status.success(),
"wt switch pr:42 --yes failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let marker = repo.root_path().join("pr-hook-marker.txt");
wait_for_file_content(&marker);
assert_eq!(
fs::read_to_string(&marker).unwrap().trim(),
"PR-CONFIG-HOOK-RAN",
"post-start should run with the PR ref's config"
);
}
#[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_cli_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_cli_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_cli_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_cli_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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_base_pr_same_repo", cmd);
});
}
#[rstest]
fn test_switch_base_pr_sets_upstream(#[from(repo_with_remote)] mut repo: TestRepo) {
let pr_source_branch = "feature-this-is-a-very-long-pr-source-branch-name";
repo.add_worktree(pr_source_branch);
repo.run_git(&["push", "origin", pr_source_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 = format!(
r#"{{
"title": "Some PR title",
"user": {{"login": "alice"}},
"state": "open",
"draft": false,
"head": {{
"ref": "{pr_source_branch}",
"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/2648"
}}"#
);
let mock_bin = setup_mock_gh_for_pr(&repo, Some(&gh_response));
let mut cmd = repo.wt_command();
cmd.args([
"switch", "--create", "swa-65", "--base", "pr:2648", "--no-cd",
]);
configure_mock_cli_env(&mut cmd, &mock_bin);
let status = cmd.status().expect("wt switch should run");
assert!(status.success(), "wt switch failed: {:?}", status);
let remote = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "--get", "branch.swa-65.remote"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
let merge = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "--get", "branch.swa-65.merge"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
assert_eq!(
remote, "origin",
"branch.swa-65.remote should be set so `git push` knows where to push"
);
assert_eq!(
merge,
format!("refs/heads/{pr_source_branch}"),
"branch.swa-65.merge should target the PR's source branch on the remote"
);
}
#[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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_empty_branch", cmd);
});
}
fn setup_mock_tea(repo: &TestRepo, tea_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, "tea");
if let Some(response) = tea_response {
fs::write(mock_bin.join("tea_pr_response.json"), response).unwrap();
MockConfig::new("tea")
.version("tea version development (mock)")
.command("api", MockResponse::file("tea_pr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
}
mock_bin
}
#[rstest]
fn test_switch_pr_gitea_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let tea_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "feature-auth",
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_create_conflict", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_base_conflict(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
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_gitea_base_conflict", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_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://gitea.example.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitea.example.com/owner/test-repo.git",
]);
let tea_response = r#"{
"title": "Fix authentication bug in login flow",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "feature-auth",
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_same_repo", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "gitea-pr-source"]);
fs::write(
repo.root_path().join("gitea-pr-file.txt"),
"Gitea PR content",
)
.unwrap();
repo.run_git(&["add", "gitea-pr-file.txt"]);
repo.run_git(&["commit", "-m", "Gitea 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://gitea.example.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitea.example.com/owner/test-repo.git",
]);
let tea_response = r#"{
"title": "Add feature fix for edge case",
"user": {"login": "contributor"},
"state": "open",
"draft": false,
"head": {
"label": "contributor:feature-fix",
"ref": "feature-fix",
"repo": {"name": "test-repo", "owner": {"login": "contributor"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/42"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_fork", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_not_found(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.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, "tea");
MockConfig::new("tea")
.version("tea version development (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", &["pr:9999"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_not_found", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_tea_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.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_gitea_tea_not_installed", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_forge_platform(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://git.internal.example.com/owner/test-repo.git",
]);
let project_config = repo.root_path().join(".config/wt.toml");
fs::create_dir_all(project_config.parent().unwrap()).unwrap();
fs::write(&project_config, "[forge]\nplatform = \"gitea\"\n").unwrap();
let tea_response = r#"{
"title": "Override test",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "feature-auth",
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://git.internal.example.com/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_forge_platform", cmd);
});
}
#[rstest]
fn test_switch_pr_forge_platform_gitlab_rejects_pr(#[from(repo_with_remote)] repo: TestRepo) {
let project_config = repo.root_path().join(".config/wt.toml");
fs::create_dir_all(project_config.parent().unwrap()).unwrap();
fs::write(&project_config, "[forge]\nplatform = \"gitlab\"\n").unwrap();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
assert_cmd_snapshot!("switch_pr_forge_platform_gitlab", cmd);
});
}
#[rstest]
fn test_switch_pr_forge_platform_invalid_bails(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
let project_config = repo.root_path().join(".config/wt.toml");
fs::create_dir_all(project_config.parent().unwrap()).unwrap();
fs::write(&project_config, "[forge]\nplatform = \"bitbucket\"\n").unwrap();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
assert_cmd_snapshot!("switch_pr_forge_platform_invalid_bails", cmd);
});
}
#[rstest]
fn test_switch_pr_no_remote_defaults_to_github(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", &["pr:101"], None);
configure_cli_not_installed_env(&mut cmd, &minimal_bin);
assert_cmd_snapshot!("switch_pr_no_remote_defaults_to_github", cmd);
});
}
#[rstest]
fn test_switch_pr_gitlab_remote_rejects_pr(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
assert_cmd_snapshot!("switch_pr_gitlab_remote", cmd);
});
}
#[rstest]
fn test_switch_pr_self_hosted_tea_authed_dispatches_to_gitea(
#[from(repo_with_remote)] repo: TestRepo,
) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://forge.selfhosted.test/owner/test-repo.git",
]);
let tea_config_dir = repo.home_path().join(".config").join("tea");
fs::create_dir_all(&tea_config_dir).unwrap();
fs::write(
tea_config_dir.join("config.yml"),
"logins:\n - name: selfhosted\n url: https://forge.selfhosted.test\n default: true\n",
)
.unwrap();
let tea_response = r#"{
"title": "Routed via tea config",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "feature-auth",
"ref": "feature-auth",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://forge.selfhosted.test/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_self_hosted_tea_authed", cmd);
});
}
#[rstest]
fn test_switch_pr_self_hosted_defaults_to_github(#[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://forge.example.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://forge.example.com/owner/test-repo.git",
]);
let gh_response = r#"{
"title": "Self-hosted defaults to gh",
"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://forge.example.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_self_hosted_defaults_to_github", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_invalid_json(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let mock_bin = setup_mock_tea(&repo, Some("not json {"));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_invalid_json", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_server_error(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.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, "tea");
MockConfig::new("tea")
.version("tea version development (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"500 Internal Server Error"}"#)
.with_stderr("tea: server error\n")
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_server_error", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_no_source_branch(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let tea_response = r#"{
"title": "Stuck PR",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "",
"ref": "",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_no_source_branch", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_deleted_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.com/owner/test-repo.git",
]);
let tea_response = r#"{
"title": "Deleted fork PR",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {
"label": "alice:gone",
"ref": "gone",
"repo": null
},
"base": {
"label": "main",
"ref": "main",
"repo": {"name": "test-repo", "owner": {"login": "owner"}}
},
"html_url": "https://gitea.example.com/owner/test-repo/pulls/101"
}"#;
let mock_bin = setup_mock_tea(&repo, Some(tea_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_deleted_fork", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_unauthorized(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.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, "tea");
MockConfig::new("tea")
.version("tea version development (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", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_unauthorized", cmd);
});
}
#[rstest]
fn test_switch_pr_gitea_forbidden(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitea.example.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, "tea");
MockConfig::new("tea")
.version("tea version development (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"403 Forbidden"}"#).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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_gitea_forbidden", cmd);
});
}
fn setup_mock_az(repo: &TestRepo, az_pr_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, "az");
if let Some(response) = az_pr_response {
fs::write(mock_bin.join("az_pr_response.json"), response).unwrap();
MockConfig::new("az")
.version("azure-cli 2.60.0 (mock)")
.command("repos pr show", MockResponse::file("az_pr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
}
mock_bin
}
fn set_azure_remote_url(repo: &TestRepo, azure_url: &str) {
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", azure_url]);
repo.run_git(&["config", &format!("url.{}.insteadOf", bare_url), azure_url]);
}
#[rstest]
fn test_switch_pr_azure_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let az_response = r#"{
"title": "Fix authentication bug in login flow",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-auth",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"},
"webUrl": "https://dev.azure.com/myorg/myproject/_git/test-repo"
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_create_conflict", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_base_conflict(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
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_azure_base_conflict", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
set_azure_remote_url(
&repo,
"https://dev.azure.com/myorg/myproject/_git/test-repo",
);
let az_response = r#"{
"title": "Fix authentication bug in login flow",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-auth",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"},
"webUrl": "https://dev.azure.com/myorg/myproject/_git/test-repo"
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_same_repo", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_visualstudio_host(#[from(repo_with_remote)] mut repo: TestRepo) {
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
set_azure_remote_url(
&repo,
"https://myorg.visualstudio.com/myproject/_git/test-repo",
);
let az_response = r#"{
"title": "Fix authentication bug in login flow",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-auth",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"},
"webUrl": "https://myorg.visualstudio.com/myproject/_git/test-repo"
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_visualstudio_host", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_fork(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["checkout", "-b", "azure-pr-source"]);
fs::write(
repo.root_path().join("azure-pr-file.txt"),
"Azure PR content",
)
.unwrap();
repo.run_git(&["add", "azure-pr-file.txt"]);
repo.run_git(&["commit", "-m", "Azure 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"]);
set_azure_remote_url(
&repo,
"https://dev.azure.com/myorg/myproject/_git/test-repo",
);
let az_response = r#"{
"title": "Add feature fix for edge case",
"createdBy": {"uniqueName": "contributor@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-fix",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"},
"webUrl": "https://dev.azure.com/myorg/myproject/_git/test-repo"
},
"forkSource": {
"repository": {
"remoteUrl": "https://dev.azure.com/myorg/myproject/_git/test-repo-fork",
"sshUrl": "git@ssh.dev.azure.com:v3/myorg/myproject/test-repo-fork"
}
}
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_fork", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_not_found(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "az");
MockConfig::new("az")
.version("azure-cli 2.60.0 (mock)")
.command(
"repos pr show",
MockResponse::stderr(
"TF401174: The requested pull request was not found, or it does not exist.\n",
)
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_not_found", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_az_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
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_azure_az_not_installed", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_forge_platform(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://git.internal.example.com/owner/test-repo.git",
]);
let project_config = repo.root_path().join(".config/wt.toml");
fs::create_dir_all(project_config.parent().unwrap()).unwrap();
fs::write(&project_config, "[forge]\nplatform = \"azure-devops\"\n").unwrap();
let az_response = r#"{
"title": "Override test",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-auth",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"},
"webUrl": "https://dev.azure.com/myorg/myproject/_git/test-repo"
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_forge_platform", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_invalid_json(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let mock_bin = setup_mock_az(&repo, Some("not json {"));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_invalid_json", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_server_error(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "az");
MockConfig::new("az")
.version("azure-cli 2.60.0 (mock)")
.command(
"repos pr show",
MockResponse::stderr("az: TF400898: An internal error occurred.\n").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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_server_error", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_auth_error(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "az");
MockConfig::new("az")
.version("azure-cli 2.60.0 (mock)")
.command(
"repos pr show",
MockResponse::stderr("Please run 'az login' to setup account.\n").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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_auth_error", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_extension_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://dev.azure.com/myorg/myproject/_git/test-repo",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
copy_mock_binary(&mock_bin, "az");
MockConfig::new("az")
.version("azure-cli 2.60.0 (mock)")
.command(
"repos pr show",
MockResponse::stderr(
"ERROR: 'repos' is misspelled or not recognized by the system. \
The command requires the azure-devops extension.\n",
)
.with_exit_code(2),
)
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_extension_not_installed", cmd);
});
}
#[rstest]
fn test_switch_pr_azure_org_undeterminable(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://git.internal.example.com/owner/test-repo.git",
]);
let project_config = repo.root_path().join(".config/wt.toml");
fs::create_dir_all(project_config.parent().unwrap()).unwrap();
fs::write(&project_config, "[forge]\nplatform = \"azure-devops\"\n").unwrap();
let az_response = r#"{
"title": "No web URL",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/feature-auth",
"repository": {
"name": "test-repo",
"project": {"name": "myproject"}
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, Some(az_response));
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["pr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_org_undeterminable", 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"]);
}