Files
max-sixty__worktrunk/tests/integration_tests/switch.rs

7616 lines
267 KiB
Rust

use crate::common::{
SLEEP_FOR_ABSENCE_CHECK, 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;
// Snapshot helpers
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(|| {
// Directive file guards - declared at closure scope to live through command execution
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);
});
}
// Basic switch tests
#[rstest]
fn test_switch_create_new_branch(repo: TestRepo) {
snapshot_switch("switch_create_new", &repo, &["--create", "feature-x"]);
}
/// Test that delayed streaming shows progress message when threshold is 0.
/// This exercises the streaming code path that normally only triggers for slow operations.
#[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);
// Force immediate streaming by setting threshold to 0
cmd.env("WORKTRUNK_TEST_DELAYED_STREAM_MS", "0");
assert_cmd_snapshot!("switch_create_with_progress", cmd);
});
}
/// `git worktree add` runs through the delayed-stream path (`Cmd::delayed_stream`),
/// which historically spawned a raw child and emitted no trace record — it
/// surfaced only as an unattributed gap in the `wt-perf` timeline. Assert the
/// streamed command is now traced, pinning the `CommandTrace` chokepoint
/// (`src/trace/emit.rs`) against regression. The record is emitted whether the
/// command buffers or streams, so no delay threshold is forced.
#[rstest]
fn test_switch_create_traces_worktree_add(repo: TestRepo) {
use worktrunk::trace::{TraceEntryKind, parse_lines};
// -vv writes the machine `trace.jsonl` (the records `wt-perf timeline`
// reads), parsed back here the same way.
let output = repo
.wt_command()
.args(["-vv", "switch", "--create", "feature-traced", "--no-cd"])
.output()
.expect("wt switch should run");
assert!(
output.status.success(),
"switch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let trace = std::fs::read_to_string(repo.root_path().join(".git/wt/logs/trace.jsonl"))
.expect("-vv should write trace.jsonl");
let commands: Vec<String> = parse_lines(&trace)
.into_iter()
.filter_map(|e| match e.kind {
TraceEntryKind::Command { command, .. } => Some(command),
_ => None,
})
.collect();
assert!(
commands.iter().any(|c| c.contains("worktree add")),
"expected a trace command record for `git worktree add`; \
got command records:\n{}",
commands.join("\n")
);
}
#[rstest]
fn test_switch_create_existing_branch_error(mut repo: TestRepo) {
// Create a branch first
repo.add_worktree("feature-y");
// Try to create it again - should error
snapshot_switch(
"switch_create_existing_error",
&repo,
&["--create", "feature-y"],
);
}
/// Creating a branch whose name is a path prefix of an existing branch is a git
/// ref directory/file conflict: `release` can't be created while `release/2026.4`
/// exists. Regression for #3527 — surface a clear namespace-collision error
/// instead of git's raw "cannot lock ref" text.
#[rstest]
fn test_switch_create_branch_namespace_conflict(repo: TestRepo) {
// An existing branch living under the `release/` namespace.
repo.run_git(&["branch", "release/2026.4"]);
// `release` can't be both a branch and a directory of branches.
snapshot_switch(
"switch_create_namespace_conflict",
&repo,
&["--create", "release"],
);
}
/// When --execute is passed and the branch already exists, the error hint should
/// include --execute and trailing args in the suggested command.
#[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",
],
);
}
/// When --execute carries shell metacharacters (spaces, `$(...)`, embedded
/// single quotes), the rendered suggestion must POSIX-single-quote the value
/// so a copy-paste into bash/zsh/fish runs the intended literal command
/// instead of executing a command substitution. Guards the
/// `unix::escape(exec)` call on `run_switch`'s `suggestion_ctx` path against
/// regressing to platform-sensitive escaping (which on Windows-without-MSYSTEM
/// produces cmd.exe double-quote quoting — still subject to POSIX command
/// substitution when spliced into bash).
#[rstest]
fn test_switch_create_existing_with_execute_metachars(mut repo: TestRepo) {
repo.add_worktree("emails");
snapshot_switch(
"switch_create_existing_with_execute_metachars",
&repo,
&[
"--create",
"--execute=echo \"$x's\"",
"emails",
"--",
"Check my emails",
],
);
}
/// When --execute is passed and the branch doesn't exist (without --create),
/// the "create" suggestion should include --execute and trailing args.
#[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) {
// Create a branch on the remote only (no local branch)
repo.run_git(&["branch", "remote-feature"]);
repo.run_git(&["push", "origin", "remote-feature"]);
// Delete the local branch
repo.run_git(&["branch", "-D", "remote-feature"]);
// Now we have origin/remote-feature but no local remote-feature
// This should succeed with --create (previously would fail)
snapshot_switch(
"switch_create_remote_only",
&repo,
&["--create", "remote-feature"],
);
}
/// Git's DWIM creates local tracking branch from remote when no local branch exists.
/// Should report "Created branch X (tracking remote)" since DWIM actually created the branch.
#[rstest]
fn test_switch_dwim_from_remote(#[from(repo_with_remote)] repo: TestRepo) {
// Create a branch on the remote only (no local branch)
repo.run_git(&["branch", "dwim-feature"]);
repo.run_git(&["push", "origin", "dwim-feature"]);
repo.run_git(&["branch", "-D", "dwim-feature"]);
// Now we have origin/dwim-feature but no local dwim-feature
// DWIM should create local branch from remote
snapshot_switch("switch_dwim_from_remote", &repo, &["dwim-feature"]);
}
/// When the branch argument includes the remote prefix (e.g., "origin/feature"),
/// strip the prefix and switch to the local branch via DWIM.
/// This happens when the interactive picker returns a remote branch name.
#[rstest]
fn test_switch_remote_prefix_stripped(#[from(repo_with_remote)] repo: TestRepo) {
// Create a branch on the remote only (no local branch)
repo.run_git(&["branch", "remote-feature"]);
repo.run_git(&["push", "origin", "remote-feature"]);
repo.run_git(&["branch", "-D", "remote-feature"]);
// Passing "origin/remote-feature" should strip the prefix and DWIM to local branch
snapshot_switch(
"switch_remote_prefix_stripped",
&repo,
&["origin/remote-feature"],
);
}
/// When the branch name contains slashes (e.g., "username/feature-1") and the picker
/// returns it with the remote prefix ("origin/username/feature-1"), the remote prefix
/// should be stripped correctly. Regression test for #1260.
#[rstest]
fn test_switch_remote_prefix_stripped_slash_in_branch(#[from(repo_with_remote)] repo: TestRepo) {
// Create a branch with / in the name on the remote only
repo.run_git(&["branch", "username/feature-1"]);
repo.run_git(&["push", "origin", "username/feature-1"]);
repo.run_git(&["branch", "-D", "username/feature-1"]);
// Passing "origin/username/feature-1" should strip "origin/" and DWIM correctly
snapshot_switch(
"switch_remote_prefix_slash_branch",
&repo,
&["origin/username/feature-1"],
);
}
/// A local branch literally named like a remote-tracking ref must win over the
/// remote-prefix stripping used for picker/DWIM remote-only branches.
#[rstest]
fn test_switch_remote_prefix_preserves_same_named_local_branch(
#[from(repo_with_remote)] repo: TestRepo,
) {
// Remote `collision`, with no local `collision` branch.
repo.run_git(&["branch", "collision"]);
repo.run_git(&["push", "origin", "collision"]);
repo.run_git(&["branch", "-D", "collision"]);
// Local branch literally named `origin/collision`.
repo.run_git(&["checkout", "-b", "origin/collision"]);
fs::write(repo.root_path().join("local-collision.txt"), "local").unwrap();
repo.run_git(&["add", "."]);
repo.run_git(&["commit", "-m", "Local origin/collision"]);
repo.run_git(&["checkout", "main"]);
let output = repo
.wt_command()
.args(["switch", "origin/collision"])
.output()
.unwrap();
assert!(
output.status.success(),
"switch should succeed; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let worktrees = repo.git_output(&["worktree", "list", "--porcelain"]);
assert!(
worktrees.contains("branch refs/heads/origin/collision"),
"should create worktree for local origin/collision, not stripped collision: {worktrees}"
);
let stripped_branch = repo
.git_command()
.args(["show-ref", "--verify", "refs/heads/collision"])
.run()
.unwrap();
assert!(
!stripped_branch.status.success(),
"switch must not create stripped local branch collision"
);
}
/// When a branch exists on multiple remotes, DWIM should fail with an error
/// since git can't determine which remote to track.
#[rstest]
fn test_switch_dwim_ambiguous_remotes(#[from(repo_with_remote)] mut repo: TestRepo) {
// Add a second remote
repo.setup_custom_remote("upstream", "main");
// Create a branch on both remotes (no local branch)
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"]);
// Now shared-feature exists on origin and upstream but not locally
// DWIM can't pick — git worktree add should error
snapshot_switch("switch_dwim_ambiguous_remotes", &repo, &["shared-feature"]);
}
/// `--base <branch>` should accept a branch that exists only as a remote-tracking ref
/// (e.g., `origin/releases/4.x.x`) when the user passes the bare name. Without this,
/// `wt switch -c new --base releases/4.x.x` fails the pre-validation in
/// `resolve_switch_target` even though `git worktree add` would have DWIM'd it.
/// Regression test for GitHub issue #2410.
#[rstest]
fn test_switch_create_with_remote_only_base(#[from(repo_with_remote)] repo: TestRepo) {
// Create a branch with slashes/dots on the remote only (matches the issue repro).
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)
);
// The new branch must exist and must NOT track the remote base
// (same safety property as test_switch_create_from_remote_base_no_upstream).
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"
);
}
/// When creating a new branch from a remote tracking branch (e.g., origin/main),
/// the new branch should NOT track the remote base branch.
/// This prevents accidental `git push` to the base branch (e.g., pushing to main).
/// This is the bug fix for GitHub issue #713.
#[rstest]
fn test_switch_create_from_remote_base_no_upstream(#[from(repo_with_remote)] repo: TestRepo) {
// Create a new branch with --base pointing to a remote tracking branch
let output = repo
.wt_command()
.args(["switch", "--create", "my-feature", "--base=origin/main"])
.output()
.unwrap();
assert!(output.status.success(), "switch should succeed");
// Verify the branch was created
let branch_output = repo.git_output(&["branch", "--list", "my-feature"]);
assert!(
branch_output.contains("my-feature"),
"branch should be created"
);
// Verify the branch does NOT have an upstream (no tracking)
// Using rev-parse to check for upstream - should fail for untracked branches
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)"
);
}
/// When local branch already exists and tracks a remote, should report
/// "Created worktree for X" NOT "Created branch X (tracking remote)".
/// This is the bug fix for GitHub issue #656.
#[rstest]
fn test_switch_existing_local_branch_with_upstream(#[from(repo_with_remote)] repo: TestRepo) {
// Create local branch tracking remote
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"]);
// Switch to the existing local branch (should NOT say "Created branch")
snapshot_switch(
"switch_existing_local_with_upstream",
&repo,
&["tracked-feature"],
);
}
#[rstest]
fn test_switch_existing_branch(mut repo: TestRepo) {
repo.add_worktree("feature-z");
// Switch to it (should find existing worktree)
snapshot_switch("switch_existing_branch", &repo, &["feature-z"]);
}
///
/// When shell integration is configured in user's rc files (e.g., .zshrc) but the user
/// runs `wt` binary directly (not through the shell wrapper), show a warning that explains
/// the actual situation: shell IS configured, but cd can't happen because we're not
/// running through the shell function.
///
/// Since tests run via `cargo test`, argv[0] contains a path (`target/debug/wt`), which
/// triggers the "explicit path" code path. The warning explains that shell integration
/// won't intercept explicit paths.
///
/// Skipped on Windows: the binary is `wt.exe` so a different (more targeted) warning is
/// shown ("use wt without .exe"). Windows-specific behavior is tested in unit tests.
#[rstest]
#[cfg(not(windows))]
fn test_switch_existing_with_shell_integration_configured(mut repo: TestRepo) {
use std::fs;
// Create a worktree first
repo.add_worktree("shell-configured");
// Simulate shell integration configured in user's shell rc files
// (repo.home_path() is automatically set as HOME by configure_wt_cmd)
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();
// Switch to existing worktree - should show warning about binary invoked directly
// (different from "no shell integration" warning when shell is not configured at all)
// Note: Must set SHELL=/bin/zsh so scan_shell_configs() looks for .zshrc
snapshot_switch_with_shell(
"switch_existing_with_shell_configured",
&repo,
&["shell-configured"],
"/bin/zsh",
);
}
///
/// When git runs a subcommand, it sets `GIT_EXEC_PATH` in the environment.
/// Shell integration cannot work in this case because cd directives cannot
/// propagate through git's subprocess to the parent shell.
#[rstest]
fn test_switch_existing_as_git_subcommand(mut repo: TestRepo) {
// Create a worktree first
repo.add_worktree("git-subcommand-test");
// Switch with GIT_EXEC_PATH set (simulating `git wt switch ...`)
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) {
// Issues #562, #977: Error message should identify the invalid base branch,
// not the target branch being created
snapshot_switch(
"switch_create_invalid_base",
&repo,
&["--create", "new-feature", "--base", "nonexistent-base"],
);
}
/// A path naming a directory that holds no worktree is reported as a directory,
/// not as a missing branch — and, unlike a name that could be one, carries no
/// `--create` suggestion, since git rejects a path spelling as a branch name.
#[rstest]
fn test_switch_path_holding_no_worktree(repo: TestRepo) {
let leftover = repo.root_path().parent().unwrap().join("repo.leftover");
fs::create_dir_all(&leftover).unwrap();
snapshot_switch(
"switch_path_holding_no_worktree",
&repo,
&["../repo.leftover"],
);
}
/// A worktree whose directory was deleted and *recreated* gets the same
/// "directory missing" answer as one merely deleted, rather than a raw `git
/// rev-parse --git-dir failed (exit 128)`.
///
/// The recreation is the point: `Path::exists()` passes it, which is why every
/// command that resolved the branch used to walk on into git's failure. Asking
/// git's own `prunable` instead collapses the two spellings of one state onto
/// one message — see `test_switch_error_missing_worktree_directory` for the
/// deleted half.
#[rstest]
fn test_switch_worktree_directory_recreated(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature");
fs::remove_dir_all(&worktree_path).unwrap();
fs::create_dir_all(&worktree_path).unwrap();
snapshot_switch("switch_worktree_directory_recreated", &repo, &["feature"]);
}
/// `--create` names a branch that does not exist yet, so the argument is never
/// tried as a path — even when a worktree is registered at that spelling.
///
/// The registered worktree is on a *different* branch, so resolving the
/// argument as a path would return that worktree and silently drop `--create`.
/// `resolve_switch_target`'s own `--create` guards do not catch it: they reject
/// a branch that already exists locally, and this one does not.
#[rstest]
fn test_switch_create_ignores_a_worktree_at_that_path(mut repo: TestRepo) {
let occupied = repo.root_path().join("docs");
repo.add_worktree_at_path("other", &occupied);
let output = repo
.wt_command()
.args(["switch", "--create", "docs", "--no-hooks"])
.output()
.unwrap();
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let branches = repo.git_output(&["branch", "--format=%(refname:short)"]);
assert!(
branches.lines().any(|b| b == "docs"),
"--create must create the branch rather than switching to the worktree \
registered at ./docs, got branches: {branches}"
);
}
/// A trailing separator is stripped before resolution, so a branch whose name
/// matches a directory in the repository is still found.
///
/// This is the shape shell completion produces: `wt switch docs<tab>` completes
/// against `./docs` and yields `docs/`, which git's ref format rejects as a
/// branch name — so the branch lookup used to miss a branch sitting right
/// there.
#[rstest]
fn test_switch_branch_name_with_trailing_separator(mut repo: TestRepo) {
fs::create_dir_all(repo.root_path().join("docs")).unwrap();
fs::write(repo.root_path().join("docs/index.md"), "docs").unwrap();
repo.commit("add docs directory");
repo.add_worktree("docs");
snapshot_switch(
"switch_branch_name_with_trailing_separator",
&repo,
&["docs/"],
);
}
#[rstest]
fn test_switch_nonexistent_branch(repo: TestRepo) {
// Switching to a nonexistent branch (without --create) should give a clear
// "branch not found" error, not fall through to a confusing git error.
snapshot_switch("switch_nonexistent_branch", &repo, &["nonexistent-branch"]);
}
#[rstest]
fn test_switch_nonexistent_branch_with_fetch_time(repo: TestRepo) {
// When FETCH_HEAD exists, the hint should include "last fetched X ago".
let git_dir = repo.root_path().join(".git");
fs::write(git_dir.join("FETCH_HEAD"), "").unwrap();
// Set TEST_EPOCH to 3 hours after the real mtime so the file appears "3h ago"
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) {
// Issue #630: --base should accept any commit-ish, not just branch names
// Test HEAD as base (common use case: branch from current HEAD)
repo.commit("Initial commit on main");
snapshot_switch(
"switch_base_commitish_head",
&repo,
&["--create", "feature-from-head", "--base", "HEAD"],
);
}
// Internal mode tests
#[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],
);
}
/// The `--execute` no-integration fallback relocates the payload into the
/// switch target, so inherited git-discovery vars (`GIT_DIR`/`GIT_WORK_TREE`)
/// must be scrubbed there like at the hook and for-each spawn sites — git
/// resolves them before the cwd, so a forwarded value would misdirect the
/// payload's `git` calls to the inherited repo while the "Executing @ …"
/// header names the target (issue #3373; classification in
/// `scrub_git_discovery_env_vars`).
///
/// `repo.wt_command()` configures no directive files, so wt executes the
/// payload itself (unix: exec, non-unix: spawn — each platform's CI drives
/// its own variant). The relative marker path resolves in the payload's cwd,
/// pinning the relocation at the same time.
#[rstest]
fn test_switch_execute_fallback_does_not_inherit_git_discovery_vars(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let output = repo
.wt_command()
.args([
"switch",
"feature",
"--execute",
r#"printf '[%s][%s]' "$GIT_DIR" "$GIT_WORK_TREE" > git_env_seen.txt"#,
])
.env("GIT_DIR", repo.root_path().join(".git"))
.env("GIT_WORK_TREE", repo.root_path())
.output()
.unwrap();
assert!(
output.status.success(),
"switch --execute failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let seen = fs::read_to_string(feature_path.join("git_env_seen.txt"))
.expect("payload should have run in the target worktree");
assert_eq!(
seen, "[][]",
"--execute payload inherited git-discovery vars ([GIT_DIR][GIT_WORK_TREE]): {seen}"
);
}
/// `--execute` computes only the template variables its command names.
///
/// The context map built at that call site feeds `expand_template` and nothing
/// else — the payload reaches the child as a shell string, never as JSON on
/// stdin, and `--execute` renders no `-v` variables table — so a var the
/// templates don't reference is a git subprocess whose result is discarded.
/// `var_default_branch` is the one with teeth: on a clone with no
/// `refs/remotes/origin/HEAD` and no cached `worktrunk.default-branch` it falls
/// through to `git ls-remote`, so an unfiltered context puts a variable-free
/// `wt switch -x` on the network.
///
/// The `var_*` spans in the `-vv` trace are that work made observable. The
/// second case is the control: it proves the trace captures spans from this
/// call site, so the first case's empty set is the filter working rather than
/// the trace missing them.
#[rstest]
fn test_switch_execute_computes_only_referenced_vars(mut repo: TestRepo) {
repo.add_worktree("exec-vars");
let trace = repo.root_path().join(".git/wt/logs/trace.jsonl");
// Each `-vv` run replaces trace.jsonl, so the two cases don't accumulate.
let var_spans = |execute: &str| -> Vec<String> {
let output = repo
.wt_command()
.args(["-vv", "switch", "exec-vars", "--execute", execute])
.output()
.unwrap();
assert!(
output.status.success(),
"switch --execute '{execute}' failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let raw = fs::read_to_string(&trace).expect("-vv should write trace.jsonl");
let mut spans: Vec<String> = raw
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|record| record["kind"] == "span")
.filter_map(|record| record["span"].as_str().map(str::to_owned))
.filter(|span| span.starts_with("var_"))
.collect();
spans.sort();
spans
};
let none_referenced = var_spans("echo hi");
assert!(
none_referenced.is_empty(),
"a --execute command naming no variables should compute none, got {none_referenced:?}"
);
assert_eq!(
var_spans("echo {{ commit }}"),
["var_commit"],
"a --execute command naming commit should compute exactly that"
);
}
/// `--execute` with trailing `-- args` containing shell metacharacters: the
/// constructed command appended to the exec directive file must POSIX-escape
/// each trailing arg so the user's shell wrapper (`sh -c`, `bash -c`, …)
/// reads them as literal arguments — no command substitution, no $-expansion,
/// embedded single quotes via the standard `'\''` idiom. Guards
/// `run_switch`'s `escaped_args` map at `worktree/switch.rs` from regressing
/// to platform-sensitive escaping.
#[rstest]
fn test_switch_with_execute_trailing_args_metachars(repo: TestRepo) {
let (cd_path, exec_path, _guard) = directive_files();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&[
"--create",
"exec-trailing",
"--execute",
"echo",
"--",
"$x's hello",
"with space",
],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
assert_cmd_snapshot!("switch_with_execute_trailing_args_metachars_stderr", cmd);
// The exec file is what the shell wrapper would source. It must contain
// the trailing args POSIX-escaped — exactly the form a POSIX shell
// parses back to the original literal values. (No command
// substitution, no `$` expansion; embedded single quote uses the
// standard `'\''` idiom.)
let exec_contents = fs::read_to_string(&exec_path).unwrap();
insta::assert_snapshot!(
"switch_with_execute_trailing_args_metachars_exec",
&exec_contents
);
});
}
/// Same scenario as `test_switch_with_execute_trailing_args_metachars`, but
/// with `WORKTRUNK_SHELL=powershell` — the env var the PowerShell wrapper
/// sets. The wrapper evaluates the EXEC directive file via `Invoke-Expression`,
/// where the POSIX `'\''` idiom is invalid; each trailing arg must instead use
/// PowerShell single-quoting (embedded `'` doubled to `''`). Guards the
/// `directive_shell_escape_mode()` branch in `run_switch`'s `escaped_args`.
#[rstest]
fn test_switch_with_execute_trailing_args_metachars_powershell(repo: TestRepo) {
let (cd_path, exec_path, _guard) = directive_files();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&[
"--create",
"exec-trailing-psh",
"--execute",
"echo",
"--",
"$x's hello",
"with space",
],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.env("WORKTRUNK_SHELL", "powershell");
assert_cmd_snapshot!(
"switch_with_execute_trailing_args_metachars_powershell_stderr",
cmd
);
// Under PowerShell, the exec file must contain the trailing args
// PowerShell-escaped: wrapped in `'…'` with embedded `'` doubled to
// `''`. `$(...)` / `$x` are inert inside a PowerShell single-quoted
// string, so they need no further escaping.
let exec_contents = fs::read_to_string(&exec_path).unwrap();
insta::assert_snapshot!(
"switch_with_execute_trailing_args_metachars_powershell_exec",
&exec_contents
);
});
}
/// Same scenario as `test_switch_with_execute_trailing_args_metachars`, but
/// with `WORKTRUNK_SHELL=fish` — the env var the fish wrapper sets. The wrapper
/// evaluates the EXEC directive file via `eval`, where fish treats `\` as an
/// escape inside `'…'`; the POSIX escaper would collapse a `\\` pair and turn
/// a trailing `\` into an unterminated string. Each trailing arg must instead
/// use fish single-quoting (`\` doubled, `'` backslash-escaped). The args
/// carry a literal backslash — the byte that POSIX and fish escaping diverge
/// on. Guards the `directive_shell_escape_mode()` branch in `run_switch`.
#[rstest]
fn test_switch_with_execute_trailing_args_metachars_fish(repo: TestRepo) {
let (cd_path, exec_path, _guard) = directive_files();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&[
"--create",
"exec-trailing-fish",
"--execute",
"echo",
"--",
r"a\\b's hello",
"with space",
],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.env("WORKTRUNK_SHELL", "fish");
assert_cmd_snapshot!(
"switch_with_execute_trailing_args_metachars_fish_stderr",
cmd
);
// Under fish, the exec file must contain the trailing args
// fish-escaped: wrapped in `'…'` with each `\` doubled and each `'`
// backslash-escaped — not the POSIX `'\''` idiom, which fish's `eval`
// would mis-parse.
let exec_contents = fs::read_to_string(&exec_path).unwrap();
insta::assert_snapshot!(
"switch_with_execute_trailing_args_metachars_fish_exec",
&exec_contents
);
});
}
/// A `--execute` value that is a shell command line rather than a single
/// program name gets a deprecation warning for the upcoming argv input model;
/// a single program name stays silent.
#[rstest]
fn test_switch_execute_argv_deprecation_warning(repo: TestRepo) {
let (cd_path, exec_path, _guard) = directive_files();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
// Shell syntax / multiple words — not a single program name.
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["--create", "dep-shell", "--execute", "echo hi && ls"],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
assert_cmd_snapshot!("switch_execute_deprecation_shell_syntax", cmd);
// A single program name — unaffected, no warning.
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["--create", "dep-ok", "--execute", "git"],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
assert_cmd_snapshot!("switch_execute_deprecation_compatible", cmd);
// Trailing args are folded into the suggested command line, not dropped.
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["--create", "dep-args", "--execute", "npm run", "--", "test"],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
assert_cmd_snapshot!("switch_execute_deprecation_trailing_args", cmd);
// Under a fish wrapper the suggestion wraps in `fish`, not POSIX `sh`.
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&[
"--create",
"dep-fish",
"--execute",
"set -lx FOO bar; echo $FOO",
],
None,
);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.env("WORKTRUNK_SHELL", "fish");
assert_cmd_snapshot!("switch_execute_deprecation_fish_wrapper", cmd);
});
}
// Error tests
#[rstest]
fn test_switch_error_missing_worktree_directory(mut repo: TestRepo) {
let wt_path = repo.add_worktree("missing-wt");
// Remove the worktree directory (but leave it registered in git)
std::fs::remove_dir_all(&wt_path).unwrap();
// Try to switch to the missing worktree (should fail)
snapshot_switch("switch_error_missing_directory", &repo, &["missing-wt"]);
}
/// Test error when target path is registered to a worktree whose directory is missing.
///
/// Scenario: branch "feature/collision" has a worktree at "repo.feature-collision",
/// but the directory was deleted. Trying to create "feature-collision" (which maps
/// to the same path) should error about the missing worktree, not try to overwrite.
#[rstest]
fn test_switch_error_path_occupied_by_missing_worktree(mut repo: TestRepo) {
// Create a worktree for "feature/collision" -> path "repo.feature-collision"
let wt_path = repo.add_worktree("feature/collision");
// Delete the worktree directory (but leave it registered in git)
std::fs::remove_dir_all(&wt_path).unwrap();
// Try to create "feature-collision" which maps to the same path
// Should fail because the path is registered to a missing worktree
snapshot_switch(
"switch_error_path_occupied_missing",
&repo,
&["--create", "feature-collision"],
);
}
#[rstest]
fn test_switch_error_path_occupied(repo: TestRepo) {
// Calculate where the worktree would be created
// Default path pattern is {repo_name}.{branch}
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));
// Create a non-worktree directory at that path
std::fs::create_dir_all(&expected_path).unwrap();
std::fs::write(expected_path.join("some_file.txt"), "occupant content").unwrap();
// Try to create a worktree with a branch that would use that path
// Should fail with worktree_path_occupied error
snapshot_switch(
"switch_error_path_occupied",
&repo,
&["--create", "occupied-branch"],
);
// Cleanup
std::fs::remove_dir_all(&expected_path).ok();
}
// Execute flag tests
#[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],
);
// Query Git rather than reconstructing the configured path template: this
// keeps the state oracle correct if the fixture's worktree-path changes.
let worktrees = repo.git_output(&["worktree", "list", "--porcelain"]);
let file_worktree = worktrees
.split("\n\n")
.find(|entry| {
entry
.lines()
.any(|line| line == "branch refs/heads/file-test")
})
.unwrap_or_else(|| panic!("file-test worktree not registered:\n{worktrees}"));
let worktree_path = file_worktree
.lines()
.find_map(|line| line.strip_prefix("worktree "))
.map(Path::new)
.unwrap_or_else(|| panic!("file-test worktree has no path:\n{file_worktree}"));
assert_eq!(
fs::read_to_string(worktree_path.join("test.txt")).unwrap(),
"test content\n",
"--execute should run in the created worktree"
);
assert!(
!repo.root_path().join("test.txt").exists(),
"--execute must not run in the source worktree"
);
}
#[rstest]
fn test_switch_execute_failure(repo: TestRepo) {
snapshot_switch(
"switch_execute_failure",
&repo,
&["--create", "fail-test", "--execute", "exit 1"],
);
}
// Execute template expansion tests
#[rstest]
fn test_switch_execute_template_branch(repo: TestRepo) {
// Test that {{ branch }} is expanded in --execute command
snapshot_switch(
"switch_execute_template_branch",
&repo,
&[
"--create",
"template-test",
"--execute",
"echo 'branch={{ branch }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_base(repo: TestRepo) {
repo.create_branch("release-base");
// A create uses the explicitly selected base, not the default branch.
snapshot_switch(
"switch_execute_template_base",
&repo,
&[
"--create",
"from-release-base",
"--base",
"release-base",
"--execute",
"echo 'base={{ base }}'",
],
);
}
#[rstest]
fn test_switch_execute_template_base_for_existing_worktree_uses_source(mut repo: TestRepo) {
// For an existing-worktree switch, `base` is the source worktree rather
// than a branch selected by `--base`. This command starts from main.
repo.add_worktree("existing");
snapshot_switch(
"switch_execute_template_base_without_create",
&repo,
&["existing", "--execute", "echo 'base={{ base }}'"],
);
}
#[rstest]
fn test_switch_execute_template_shell_escape(repo: TestRepo) {
// Test that shell metacharacters in branch names are escaped
// Without escaping, this would execute `id` as a separate command
snapshot_switch(
"switch_execute_template_shell_escape",
&repo,
&["--create", "feat;id", "--execute", "echo {{ branch }}"],
);
}
#[rstest]
fn test_switch_execute_template_in_args(repo: TestRepo) {
// Test that templates are expanded in trailing args (after --)
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) {
// Test that invalid templates are rejected with a clear error
snapshot_switch(
"switch_execute_template_error",
&repo,
&["--create", "error-test", "--execute", "echo {{ unclosed"],
);
}
#[rstest]
fn test_switch_execute_arg_template_error(repo: TestRepo) {
// Test that invalid templates in trailing args (after --) are rejected
snapshot_switch(
"switch_execute_arg_template_error",
&repo,
&[
"--create",
"arg-error-test",
"--execute",
"echo",
"--",
"valid={{ branch }}",
"invalid={{ unclosed",
],
);
}
// Verbose mode tests
#[rstest]
fn test_switch_execute_verbose_multiline_template(repo: TestRepo) {
// Test that -v shows multiline template expansion with proper formatting
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
// Multiline template with conditional
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);
});
}
// --no-hooks flag tests
#[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;
// Create project config with a command that would create a file
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");
// Pre-approve the command (repo.home_path() is automatically set as HOME)
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();
// With --no-hooks, the post-start command should be skipped
snapshot_switch(
"switch_no_hooks_skips_post_start",
&repo,
&["--create", "no-post-start", "--no-hooks"],
);
// post-start runs in the background; with --no-hooks it is never spawned,
// but sleep briefly so a regression that incorrectly spawns it has time to
// create the marker (per tests/CLAUDE.md "Testing absence").
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
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");
// With --no-hooks, the --execute command should still run
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;
// Create project config with a command that would create a file
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");
// With --no-hooks, even --yes shouldn't execute config commands
// (HOME is automatically set to repo.home_path() by configure_wt_cmd)
snapshot_switch(
"switch_no_hooks_with_yes",
&repo,
&["--create", "yes-no-hooks", "--yes", "--no-hooks"],
);
// post-start runs in the background; with --no-hooks it is never spawned,
// but sleep briefly so a regression that incorrectly spawns it has time to
// create the marker (per tests/CLAUDE.md "Testing absence").
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
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"
);
}
/// `wt switch --create` resolves `pre-start` / `post-start` from the
/// **invoking** worktree's `.config/wt.toml`, read from its working tree — so
/// an *uncommitted* config fires the hooks (the regression behind #2856 and
/// #2818). The base branch's committed `.config/wt.toml` is not consulted, even
/// though the new worktree is a checkout of it.
#[rstest]
fn test_switch_create_reads_invoking_worktree_config(mut repo: TestRepo) {
// The base branch carries a *committed* hook that must be ignored.
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 from-base-branch > {{ repo_path }}/base-branch-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"]);
// The invoking worktree (`main`, cwd) has an *uncommitted* `.config/wt.toml`.
// `{{ repo_path }}` is the main worktree root regardless of which worktree
// the hook runs in, so the markers land where the test reads.
repo.write_project_config(
r#"pre-start = "echo pre-start-from-invoking > {{ repo_path }}/pre-start-marker.txt"
post-start = "echo post-start-from-invoking > {{ repo_path }}/post-start-marker.txt"
"#,
);
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-invoking",
"pre-start should run from the invoking worktree's uncommitted 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-invoking",
"post-start should run from the invoking worktree's uncommitted config"
);
// The base branch's committed hook must never run.
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!repo.root_path().join("base-branch-marker.txt").exists(),
"the base branch's committed config must not be consulted for `--create`"
);
}
/// `wt switch <existing>` resolves `post-switch` from the **invoking** worktree's
/// `.config/wt.toml` — the worktree `wt switch` ran in — not the destination's.
#[rstest]
fn test_switch_existing_reads_invoking_worktree_config(mut repo: TestRepo) {
// The destination worktree carries its own hook, which must be ignored.
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 from-dest > {{ repo_path }}/dest-marker.txt""#,
)
.unwrap();
// The invoking worktree (`main`, cwd) carries the hook that should run.
repo.write_project_config(
r#"post-switch = "echo from-invoking > {{ repo_path }}/post-switch-marker.txt""#,
);
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(),
"from-invoking",
"post-switch should run with the invoking worktree's config"
);
// The destination worktree's own hook must never run.
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!repo.root_path().join("dest-marker.txt").exists(),
"the destination worktree's config must not be consulted"
);
}
/// A malformed `.config/wt.toml` in the invoking worktree makes `wt switch`
/// abort with the parse error in stderr — no silent fall-through to a different
/// config. The path is surfaced so the user can find and fix the offending file.
#[rstest]
fn test_switch_aborts_on_malformed_invoking_config(mut repo: TestRepo) {
repo.add_worktree("dest");
// Malformed config in the invoking worktree (`main`, cwd).
repo.write_project_config("this is not [ valid toml");
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 invoking-worktree config; stderr:\n{stderr}"
);
assert!(
stderr.contains("wt.toml"),
"error should name the offending config file; stderr:\n{stderr}"
);
}
/// `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path for *every* config read,
/// so `wt switch --create` resolves its hooks from the override file. The base
/// branch's committed `.config/wt.toml`, which the new worktree checks out, is
/// not consulted.
#[rstest]
fn test_switch_create_honors_project_config_path_override(mut repo: TestRepo) {
// The base branch carries a committed hook that must be ignored.
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"]);
// The override file (outside any worktree) is what should win.
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");
// The base ref's committed hook must never have run.
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!repo.root_path().join("wrong-marker.txt").exists(),
"the base ref's committed hook must not run when the config path is overridden"
);
}
// --no-verify backward compatibility
#[rstest]
fn test_switch_no_verify_deprecated_still_works(repo: TestRepo) {
// --no-verify should still work but emit a deprecation warning
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}"
);
}
// Branch inference and special branch tests
#[rstest]
fn test_switch_create_no_remote(repo: TestRepo) {
// Deliberately NOT calling setup_remote to test local branch inference
// Create a branch without specifying base - should infer default branch locally
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");
// Create a feature worktree using the default branch (main)
// This should work fine even though primary is on develop
snapshot_switch(
"switch_primary_on_different_branch",
&repo,
&["--create", "feature-from-main"],
);
// Also test switching to an existing branch
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) {
// No checkout history, so wt switch - should fail with helpful error
snapshot_switch("switch_previous_branch_no_history", &repo, &["-"]);
}
#[rstest]
fn test_switch_main_branch(repo: TestRepo) {
// Create a feature branch (use unique name to avoid fixture conflicts)
repo.run_git(&["branch", "test-feat-x"]);
// Switch to test-feat-x first
snapshot_switch("switch_main_branch_to_feature", &repo, &["test-feat-x"]);
// Now wt switch ^ should resolve to main
snapshot_switch("switch_main_branch", &repo, &["^"]);
}
#[rstest]
fn test_create_with_base_main(repo: TestRepo) {
// Create new branch from main using ^
snapshot_switch(
"create_with_base_main",
&repo,
&["--create", "new-feature", "--base", "^"],
);
}
#[rstest]
fn test_switch_no_warning_when_branch_matches(mut repo: TestRepo) {
// Create a worktree for "feature" branch (normal case)
repo.add_worktree("feature");
// Switch to feature with shell integration - should NOT show any warning
snapshot_switch_with_directive_file(
"switch_no_warning_when_branch_matches",
&repo,
&["feature"],
);
}
#[rstest]
fn test_switch_branch_worktree_mismatch(repo: TestRepo) {
// Create a worktree at a non-standard path (sibling to repo, not following template)
let wrong_path = repo.root_path().parent().unwrap().join("wrong-path");
repo.run_git(&[
"worktree",
"add",
wrong_path.to_str().unwrap(),
"-b",
"feature",
]);
// Switch to feature - no mismatch notice; the state shows only in wt list
snapshot_switch_with_directive_file("switch_branch_worktree_mismatch", &repo, &["feature"]);
}
///
/// Without shell integration, switching to a mismatched worktree shows only
/// the "cannot change directory" warning — no mismatch notice.
#[rstest]
fn test_switch_worktree_mismatch_no_shell_integration(repo: TestRepo) {
// Create a worktree at a non-standard path
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",
]);
// Switch without directive file (no shell integration)
snapshot_switch(
"switch_branch_worktree_mismatch_no_shell",
&repo,
&["feature-mismatch"],
);
}
///
/// When already in a worktree whose path doesn't match the branch name,
/// switching to that branch acknowledges the location with no mismatch notice.
#[rstest]
fn test_switch_already_at_with_branch_worktree_mismatch(repo: TestRepo) {
// Create a worktree at a non-standard path
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",
]);
// Switch from within the mismatched worktree (AlreadyAt case)
snapshot_switch_from_dir(
"switch_already_at_branch_worktree_mismatch",
&repo,
&["feature-already"],
&wrong_path,
);
}
///
/// With branch-first lookup, if a worktree was created for "feature" but then switched to
/// "bugfix", `wt switch feature` can't find it (since it looks by branch name). When it
/// tries to create a new worktree, it fails because the path exists. The hint shows what
/// branch currently occupies the path.
#[rstest]
fn test_switch_error_path_occupied_different_branch(repo: TestRepo) {
// Create a worktree for "feature" branch at expected path
let feature_path = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
feature_path.to_str().unwrap(),
"-b",
"feature",
]);
// Switch that worktree to a different branch "bugfix"
repo.run_git_in(&feature_path, &["switch", "-c", "bugfix"]);
// Switch to feature - should error since path is occupied by bugfix worktree
snapshot_switch_with_directive_file(
"switch_error_path_occupied_different_branch",
&repo,
&["feature"],
);
}
#[rstest]
fn test_switch_error_path_occupied_detached(repo: TestRepo) {
// Create a worktree for "feature" branch at expected path
let feature_path = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
feature_path.to_str().unwrap(),
"-b",
"feature",
]);
// Get the HEAD commit and detach
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]);
// Switch to feature - should error since path is occupied by detached worktree
snapshot_switch_with_directive_file("switch_error_path_occupied_detached", &repo, &["feature"]);
}
/// Switch to a detached worktree by absolute path (#1661).
#[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)
);
}
/// Switch to a detached worktree by relative path (#1661).
/// Relative paths with directory separators (e.g., "../repo.feature") resolve
/// against the directory wt was pointed at, which here is the process cwd.
#[rstest]
fn test_switch_detached_worktree_by_relative_path(mut repo: TestRepo) {
repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
// From the main worktree (repo/), the detached worktree is at ../repo.feature-detached
let relative_path = "../repo.feature-detached";
snapshot_switch_with_directive_file(
"switch_detached_worktree_by_relative_path",
&repo,
&[relative_path],
);
}
/// A relative path resolves against `-C`, not the process cwd — git's own rule
/// for path arguments under `-C`.
///
/// The test above reaches the worktree from a cwd inside the repo, the route
/// that works under either rule. Running from outside the repo is what tells
/// the two resolution bases apart: `../repo.feature-detached` names the
/// worktree only when it is resolved from the `-C` directory.
#[rstest]
fn test_switch_detached_worktree_by_relative_path_honors_directory_flag(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
let outside = repo.root_path().parent().unwrap().to_path_buf();
let root = repo.root_path().to_string_lossy().to_string();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = repo.wt_command();
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.current_dir(&outside)
.args(["-C", &root, "switch", "../repo.feature-detached"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"switch should resolve the relative path against -C:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(&cd_path).unwrap().trim(),
worktree_path.to_string_lossy(),
"switch should land in the worktree the path names relative to -C"
);
}
///
/// When the main worktree (repo root) has been switched to a feature branch via
/// `git checkout feature`, `wt switch main` should error with a helpful message
/// explaining how to get there. This matches GitHub issue #327.
#[rstest]
fn test_switch_main_worktree_on_different_branch(repo: TestRepo) {
// Switch the main worktree to a different branch
repo.run_git(&["checkout", "-b", "feature"]);
// Now try to switch to main - should error since main worktree is on different branch
snapshot_switch_with_directive_file(
"switch_main_worktree_on_different_branch",
&repo,
&["main"],
);
}
///
/// This reproduces GitHub issue #327: user is in a feature worktree, main worktree has been
/// switched to a different branch, and user runs `wt switch <default-branch>`.
#[rstest]
fn test_switch_default_branch_from_feature_worktree(mut repo: TestRepo) {
// Create a feature worktree to work from
let feature_a_path = repo.add_worktree("feature-a");
// Switch main worktree to a different branch (simulates user running git checkout there)
repo.run_git(&["checkout", "-b", "feature-rpa"]);
// From feature-a worktree, try to switch to main (default branch)
// This should error because main worktree is now on feature-rpa
snapshot_switch_from_dir(
"switch_default_branch_from_feature_worktree",
&repo,
&["main"],
&feature_a_path,
);
}
// Execute tests with directive file
/// The shell wrapper sources this file and propagates the exit code.
#[rstest]
fn test_switch_internal_execute_exit_code(repo: TestRepo) {
// wt succeeds (exit 0), but shell script contains "exit 42"
// Shell wrapper will eval and return 42
snapshot_switch_with_directive_file(
"switch_internal_execute_exit_code",
&repo,
&["--create", "exit-code-test", "--execute", "exit 42"],
);
}
/// When wt succeeds but the execute script would fail, wt still exits 0.
/// The shell wrapper handles the execute command's exit code.
#[rstest]
fn test_switch_internal_execute_with_output_before_exit(repo: TestRepo) {
// Execute command outputs then exits with code
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],
);
}
// History and ping-pong tests
///
/// Bug scenario: If user changes worktrees without using `wt switch` (e.g., cd directly),
/// history becomes stale. The fix ensures we always use the actual current branch
/// when recording new history, not any previously stored value.
#[rstest]
fn test_switch_previous_with_stale_history(repo: TestRepo) {
// Create branches with worktrees
for branch in ["branch-a", "branch-b", "branch-c"] {
repo.run_git(&["branch", branch]);
}
// Switch to branch-a, then branch-b to establish history
snapshot_switch("switch_stale_history_to_a", &repo, &["branch-a"]);
snapshot_switch("switch_stale_history_to_b", &repo, &["branch-b"]);
// Now manually set history to simulate user changing worktrees without wt switch.
// History stores just the previous branch (branch-a from the earlier switches).
// If user manually cd'd to branch-c's worktree, history would still say branch-a.
repo.run_git(&["config", "worktrunk.history", "branch-a"]);
// Run wt switch - from branch-b's worktree.
// Should go to branch-a (what history says), and record actual current branch as new previous.
snapshot_switch("switch_stale_history_first_dash", &repo, &["-"]);
// Run wt switch - again.
// Should go back to wherever we actually were (recorded as new previous in step above)
snapshot_switch("switch_stale_history_second_dash", &repo, &["-"]);
}
///
/// This simulates real usage with shell integration, where each `wt switch` actually
/// changes the working directory before the next command runs.
#[rstest]
fn test_switch_ping_pong_realistic(repo: TestRepo) {
// Create ping-pong branch (unique name to avoid fixture conflicts)
repo.run_git(&["branch", "ping-pong"]);
// Step 1: From main worktree, switch to ping-pong (creates worktree)
// History: current=ping-pong, previous=main
snapshot_switch_from_dir(
"ping_pong_1_main_to_feature",
&repo,
&["ping-pong"],
repo.root_path(),
);
// Calculate ping-pong worktree path
let ping_pong_path = repo.root_path().parent().unwrap().join(format!(
"{}.ping-pong",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
// Step 2: From ping-pong worktree, switch back to main
// History: current=main, previous=ping-pong
snapshot_switch_from_dir(
"ping_pong_2_feature_to_main",
&repo,
&["main"],
&ping_pong_path,
);
// Step 3: From main worktree, wt switch - should go to ping-pong
// History: current=ping-pong, previous=main
snapshot_switch_from_dir(
"ping_pong_3_dash_to_feature",
&repo,
&["-"],
repo.root_path(),
);
// Step 4: From ping-pong worktree, wt switch - should go back to main
// History: current=main, previous=ping-pong
snapshot_switch_from_dir("ping_pong_4_dash_to_main", &repo, &["-"], &ping_pong_path);
// Step 5: From main worktree, wt switch - should go to ping-pong again (ping-pong!)
// History: current=ping-pong, previous=main
snapshot_switch_from_dir(
"ping_pong_5_dash_to_feature_again",
&repo,
&["-"],
repo.root_path(),
);
}
#[rstest]
fn test_switch_no_args_requires_tty(repo: TestRepo) {
// Run switch with no arguments in non-TTY - should fail with TTY requirement
// (interactive picker requires a terminal)
snapshot_switch("switch_missing_argument_hints", &repo, &[]);
}
#[rstest]
fn test_select_deprecated_alias_requires_tty(repo: TestRepo) {
// `wt select` warns that it is deprecated, then delegates to the same
// interactive picker as `wt switch`; in a non-TTY it bails on the
// terminal requirement.
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "select", &[], None);
assert_cmd_snapshot!("select_deprecated_requires_tty", cmd);
});
}
///
/// This verifies the fix for non-Unix platforms where stdin was incorrectly
/// set to Stdio::null() instead of Stdio::inherit(), breaking interactive
/// programs like `vim`, `python -i`, or `claude`.
///
/// The test pipes input to `wt switch --execute "cat"` and verifies the
/// cat command receives and outputs that input, proving stdin was inherited.
#[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");
// Write test input to stdin and close it to signal EOF
{
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);
// The cat command should have received our input via inherited stdin
// and echoed it to stdout
assert!(
stdout.contains("stdin_inheritance_test_content"),
"Expected cat to receive piped stdin. Got stdout: {}\nstderr: {}",
stdout,
String::from_utf8_lossy(&output.stderr)
);
}
// Error context tests
#[rstest]
fn test_switch_outside_git_repo(temp_home: TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
// Run wt switch --create outside a git repo - should show "Failed to switch worktree" context
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);
});
}
// Clobber flag path backup tests
#[rstest]
fn test_switch_clobber_backs_up_stale_directory(repo: TestRepo) {
// Calculate where the worktree would be created
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));
// Create a stale directory at that path (not a worktree)
std::fs::create_dir_all(&expected_path).unwrap();
std::fs::write(expected_path.join("stale_file.txt"), "stale content").unwrap();
// With --clobber, should move the directory to .bak and create the worktree
snapshot_switch(
"switch_clobber_removes_stale_dir",
&repo,
&["--create", "--clobber", "clobber-dir-test"],
);
// Verify the worktree was created
assert!(expected_path.exists());
assert!(expected_path.is_dir());
// Verify the backup was created (TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC)
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());
// Verify stale content is preserved in backup
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) {
// Calculate where the worktree would be created
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));
// Create a file (not directory) at that path
std::fs::write(&expected_path, "stale file content").unwrap();
// With --clobber, should move the file to .bak and create the worktree
snapshot_switch(
"switch_clobber_removes_stale_file",
&repo,
&["--create", "--clobber", "clobber-file-test"],
);
// Verify the worktree was created (should be a directory now)
assert!(expected_path.is_dir());
// Verify the backup was created (TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC)
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"
);
}
/// When the computed backup path is already taken, `wt switch --clobber` does
/// not fail — it moves the stale path to the next free `-N` variant via an
/// atomic no-overwrite rename, leaving the pre-existing backup untouched. This
/// is the time-of-check/time-of-use safe path: a colliding backup name (a
/// same-second clobber, or one that raced in after planning) is never
/// overwritten the way `std::fs::rename` would.
#[rstest]
fn test_switch_clobber_falls_back_when_backup_taken(repo: TestRepo) {
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let parent = repo.root_path().parent().unwrap();
let expected_path = parent.join(format!("{}.clobber-backup-taken", repo_name));
// Stale directory at the target path, with content that must survive.
std::fs::create_dir_all(&expected_path).unwrap();
std::fs::write(expected_path.join("stale.txt"), "stale content").unwrap();
// Pre-create the backup path the timestamp would produce, so the move has
// to fall back. TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC
let taken = parent.join(format!(
"{}.clobber-backup-taken.bak.20250102-000000",
repo_name
));
std::fs::create_dir_all(&taken).unwrap();
std::fs::write(taken.join("pre-existing.txt"), "do not touch").unwrap();
snapshot_switch(
"switch_clobber_falls_back_when_backup_taken",
&repo,
&["--create", "--clobber", "clobber-backup-taken"],
);
// The worktree was created.
assert!(expected_path.is_dir());
// The pre-existing backup is untouched.
assert_eq!(
std::fs::read_to_string(taken.join("pre-existing.txt")).unwrap(),
"do not touch"
);
// The stale content was moved to the -2 fallback name.
let fallback = parent.join(format!(
"{}.clobber-backup-taken.bak.20250102-000000-2",
repo_name
));
assert!(fallback.is_dir());
assert_eq!(
std::fs::read_to_string(fallback.join("stale.txt")).unwrap(),
"stale content"
);
}
///
/// When the user runs `wt` directly (not through shell wrapper), their shell won't
/// cd to the worktree directory. Hooks should show "@ path" to clarify where they run.
#[rstest]
fn test_switch_post_hook_shows_path_without_shell_integration(repo: TestRepo) {
use std::fs;
// Create project config with a post-switch hook
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");
// Run switch WITHOUT directive file (shell integration not active)
// Use --yes to auto-approve the hook command
// The hook output should show "@ path" annotation
snapshot_switch(
"switch_post_hook_path_annotation",
&repo,
&["--create", "post-hook-test", "--yes"],
);
}
///
/// When running through the shell wrapper (directive file set), the user's shell will
/// actually cd to the worktree. Hooks don't need the path annotation.
#[rstest]
fn test_switch_post_hook_no_path_with_shell_integration(repo: TestRepo) {
use std::fs;
// Create project config with a post-switch hook
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");
// Run switch WITH directive file (shell integration active)
// The hook output should NOT show "@ path" annotation
snapshot_switch_with_directive_file(
"switch_post_hook_no_path_with_shell_integration",
&repo,
&["--create", "post-hook-shell-test", "--yes"],
);
}
/// When both post-switch and post-start hooks are configured, they should be combined
/// into a single output line with format: "Running post-switch: {names}; post-start: {names} @ path"
#[rstest]
fn test_switch_combined_post_switch_and_post_start_hooks(repo: TestRepo) {
// Create project config with both post-switch and post-start hooks
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");
// Run switch --create (triggers both post-switch and post-start)
// Should show a single combined line: "Running post-switch: project; post-start: project @ path"
snapshot_switch(
"switch_combined_hooks",
&repo,
&["--create", "combined-hooks-test", "--yes"],
);
}
#[rstest]
fn test_switch_clobber_path_with_extension(repo: TestRepo) {
// Calculate where the worktree would be created
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));
// Create a file with an extension at that path
std::fs::write(&expected_path, "file with extension").unwrap();
// With --clobber, should move the file preserving extension in backup name
snapshot_switch(
"switch_clobber_path_with_extension",
&repo,
&["--create", "--clobber", "clobber-ext.txt"],
);
// Verify the worktree was created
assert!(expected_path.is_dir());
// Verify backup path includes the original extension
// file.txt -> file.txt.bak.TIMESTAMP
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) {
// Set up custom worktree-path in user config
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}"
);
}
/// Test that the worktree-path hint is suppressed when a project-specific
/// worktree-path is configured (not just a global one).
///
/// Regression test for #1939: `has_custom_worktree_path()` only checked the
/// global `worktree-path` setting, causing a misleading hint even when a
/// project-specific template was configured.
#[rstest]
fn test_switch_create_no_hint_with_project_specific_worktree_path(repo: TestRepo) {
// Set origin to a GitHub URL so the project identifier matches
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",
]);
// Redirect GitHub URL to local bare remote for actual git operations
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/test-org/test-repo.git",
]);
// Set project-specific worktree-path (no global worktree-path set)
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}"
);
// Hint should be suppressed when project-specific worktree-path exists
assert!(
!stderr.contains("customize worktree locations"),
"Hint should be suppressed when project has custom worktree-path. stderr: {stderr}"
);
}
// ============================================================================
// PR Syntax Tests (pr:<number>)
// ============================================================================
use crate::common::mock_commands::{MockConfig, MockResponse, tea_api_include_stderr};
/// Set origin to a GitHub URL so `fetch_pr_info` can parse owner/repo.
///
/// Saves the original bare repo URL and configures `url.insteadOf` so that
/// `git fetch`/`git push` still work against the local bare remote.
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",
]);
// Redirect GitHub URL to local bare remote for actual git operations
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
}
/// Install a mock forge CLI (`gh`/`glab`) answering the `--prs` list call
/// (`pr list` / `mr list`) from a canned JSON file. Returns the mock bin dir.
fn setup_mock_forge_list(
repo: &TestRepo,
cli: &str,
list_cmd: &str,
list_json: &str,
) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("list.json"), list_json).unwrap();
MockConfig::new(cli)
.version(&format!("{cli} version 1.0.0 (mock)"))
.command(list_cmd, MockResponse::file("list.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
mock_bin
}
/// `wt switch --prs` in the headless dry-run (`WORKTRUNK_PICKER_DRY_RUN`)
/// exercises the full forge fetch + row-render path — `stream_open_prs`,
/// `fetch_github`, `parse_github_prs`, `PrEntry::display_status`, `render_grid_row`,
/// `render_pr_description` — as a normal-exit subprocess, so its coverage is
/// captured. (The interactive picker exits via skim's abort, which never
/// flushes a profile, so its `--prs` code is otherwise unmeasurable.) The mock
/// `gh pr list` keeps it off the network.
#[rstest]
fn test_switch_prs_dry_run_github(repo: TestRepo) {
// Pin the forge explicitly so detection needs no remote/network.
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let pr_json = r#"[{"number":42,"title":"Retry the flaky test","headRefName":"fix/flaky","author":{"login":"octocat"},"isDraft":false,"url":"https://github.com/owner/test-repo/pull/42","body":"Wraps the request in a retry so the suite stops flaking."}]"#;
let mock_bin = setup_mock_forge_list(&repo, "gh", "pr list", pr_json);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs (github) failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
// The fetch + parse + row-render actually ran (not a fail-soft no-op): a PR
// row was created with the token `pr:42`, which seeds its preview cache. The
// fetch path swallows every error into a stashed warning and still exits 0,
// so asserting on the dump — not just the exit code — is what makes this
// meaningful on a platform where the mock `gh` might fail to resolve.
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
assert!(
entries.iter().any(|e| e["branch"] == "pr:42"),
"expected a pr:42 cache entry proving the PR row rendered, got:\n{stdout}"
);
}
/// The `log` tab on a `--prs` row loads its commits in the background: as each
/// PR row streams in, `spawn_pr_previews` kicks off `gh pr view <n> --json
/// commits` on `COLLECT_POOL`, keyed by the row's `pr:{N}` token. The dry-run
/// path joins that work and dumps the preview cache, so a `{branch:"pr:42",
/// mode:2}` (Log) entry with non-empty bytes proves the whole mechanism end to
/// end: `spawn_compute` → `compute_pr_log` → `parse_github_commits` → cache.
///
/// `headRefOid` here is a SHA that isn't in the test repo's object store, so
/// `compute_pr_log`'s local-`git log` fast path misses and falls back to the
/// forge API — exercising that branch (the present-head fast path has its own
/// test, `test_switch_prs_dry_run_github_log_tab_local`).
#[rstest]
fn test_switch_prs_dry_run_github_log_tab(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let pr_json = r#"[{"number":42,"title":"Retry the flaky test","headRefName":"fix/flaky","headRefOid":"1111111111111111111111111111111111111111","author":{"login":"octocat"},"isDraft":false,"url":"https://github.com/owner/test-repo/pull/42"}]"#;
let commits_json = r#"{"commits":[{"oid":"abc1234500000000000000000000000000000000","messageHeadline":"Wrap the request in a retry"}]}"#;
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("list.json"), pr_json).unwrap();
fs::write(mock_bin.join("commits.json"), commits_json).unwrap();
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command("pr list", MockResponse::file("list.json"))
// `gh pr view 42 --json commits` matches the "pr view" compound key.
.command("pr view", MockResponse::file("commits.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs log tab failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
// Mode 2 is Log (see `PreviewMode`); the row keys its cache by `pr:42`.
let log_entry = entries
.iter()
.find(|e| e["branch"] == "pr:42" && e["mode"] == 2)
.unwrap_or_else(|| panic!("no pr:42 Log cache entry in dump:\n{stdout}"));
assert!(
log_entry["bytes"].as_u64().unwrap_or(0) > 0,
"log pane rendered non-empty: {log_entry}"
);
}
/// When a `--prs` row's head commit is already in the local object store, the
/// `log` tab renders the rich local `git log` instead of fetching from the
/// forge. Here `gh pr list` reports the repo's own HEAD as `headRefOid` and the
/// mocked `gh pr view` is rigged to fail — so a non-empty `pr:42` Log cache
/// entry can only have come from the local-`git log` fast path, proving the
/// `headRefOid` → `spawn_pr_previews` → `compute_pr_log` wiring short-circuits
/// the API when the commit is present.
///
/// The same run pins the deferred-fetch failure contract: the `comments` tab has
/// no local path, so its forge fetch fails here (`pr view` → exit 1), and the
/// closure caches a terminal "couldn't load" pane rather than leaving the slot
/// empty — so the present Log entry (local fast path) and the present, non-empty
/// Comments entry (failure pane) both prove the row streamed and resolved. The
/// tab shows a clear failure for the session, never a perpetual loading spinner.
#[rstest]
fn test_switch_prs_dry_run_github_log_tab_local(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let head = repo.head_sha();
let pr_json = format!(
r#"[{{"number":42,"title":"Local head","headRefName":"fix/flaky","headRefOid":"{head}","author":{{"login":"octocat"}},"isDraft":false,"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("list.json"), &pr_json).unwrap();
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command("pr list", MockResponse::file("list.json"))
// The local fast path must win: a forge `pr view` would fail here, so a
// non-empty Log entry proves the commits came from the local `git log`.
.command("pr view", MockResponse::exit(1))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs local log tab failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
let log_entry = entries
.iter()
.find(|e| e["branch"] == "pr:42" && e["mode"] == 2)
.unwrap_or_else(|| panic!("no pr:42 Log cache entry in dump:\n{stdout}"));
assert!(
log_entry["bytes"].as_u64().unwrap_or(0) > 0,
"local log pane rendered non-empty: {log_entry}"
);
// The comments tab has no local path, so its forge fetch failed (pr view →
// exit 1); the closure caches a terminal "couldn't load" pane rather than
// leaving the slot empty. Mode 7 is Comments. A non-empty entry here can only
// be that failure pane — a successful comments fetch is impossible with
// `pr view` rigged to exit 1.
let comments_entry = entries
.iter()
.find(|e| e["branch"] == "pr:42" && e["mode"] == 7)
.unwrap_or_else(|| {
panic!("failed comments fetch must cache a couldn't-load pane:\n{stdout}")
});
assert!(
comments_entry["bytes"].as_u64().unwrap_or(0) > 0,
"couldn't-load pane rendered non-empty: {comments_entry}"
);
}
/// A `--prs` row whose deferred fetches all fail caches a terminal "couldn't
/// load" pane for each tab rather than leaving the slot empty (which would
/// strand the tab on its loading spinner for the session — there's no in-session
/// retry). Here the PR carries no `headRefOid`, so the `log` tab can't take the
/// local fast path and must hit the forge API, and `gh pr view` is rigged to
/// exit 1 — so both the `log` (mode 2) and `comments` (mode 7) cache entries are
/// present and non-empty, each the couldn't-load pane.
#[rstest]
fn test_switch_prs_dry_run_github_deferred_fetch_failure(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"github\"\n");
// No headRefOid → the log tab can't render locally and must hit the forge.
let pr_json = r#"[{"number":42,"title":"No local head","headRefName":"fix/flaky","author":{"login":"octocat"},"isDraft":false,"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("list.json"), pr_json).unwrap();
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command("pr list", MockResponse::file("list.json"))
// Both deferred fetches (`pr view --json commits` / `--json comments`)
// fail, so each tab falls back to the couldn't-load pane.
.command("pr view", MockResponse::exit(1))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs deferred-failure run failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
// Both deferred tabs cached a terminal couldn't-load pane (non-empty), never
// an empty slot. Modes: 2 = Log, 7 = Comments.
for (mode, tab) in [(2, "log"), (7, "comments")] {
let entry = entries
.iter()
.find(|e| e["branch"] == "pr:42" && e["mode"] == mode)
.unwrap_or_else(|| panic!("no pr:42 {tab} cache entry in dump:\n{stdout}"));
assert!(
entry["bytes"].as_u64().unwrap_or(0) > 0,
"{tab} couldn't-load pane rendered non-empty: {entry}"
);
}
}
/// The `comments` tab (7) loads the PR discussion in the background, the same
/// way the `log` tab loads commits: `spawn_pr_previews` fires `gh pr view <n>
/// --json comments` keyed by the row's `pr:{N}` token. A `{branch:"pr:42",
/// mode:7}` entry in the dry-run cache dump proves `compute_pr_comments` →
/// `render_github_comments` → cache ran end to end. (`gh pr view --json
/// commits` and `--json comments` both match the mock's `pr view` key, so the
/// canned response carries both arrays; serde ignores the one each renderer
/// doesn't read.)
#[rstest]
fn test_switch_prs_dry_run_github_comments_tab(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let pr_json = r#"[{"number":42,"title":"Retry the flaky test","headRefName":"fix/flaky","author":{"login":"octocat"},"isDraft":false,"url":"https://github.com/owner/test-repo/pull/42"}]"#;
let view_json = r#"{"commits":[{"oid":"abc1234500000000000000000000000000000000","messageHeadline":"Wrap the request in a retry"}],"comments":[{"author":{"login":"reviewer"},"body":"Nice fix.","createdAt":"2024-12-01T00:00:00Z"}]}"#;
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("list.json"), pr_json).unwrap();
fs::write(mock_bin.join("view.json"), view_json).unwrap();
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command("pr list", MockResponse::file("list.json"))
.command("pr view", MockResponse::file("view.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs comments tab failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
// Mode 7 is Comments; the row keys its cache by `pr:42`.
let comments_entry = entries
.iter()
.find(|e| e["branch"] == "pr:42" && e["mode"] == 7)
.unwrap_or_else(|| panic!("no pr:42 Comments cache entry in dump:\n{stdout}"));
assert!(
comments_entry["bytes"].as_u64().unwrap_or(0) > 0,
"comments pane rendered non-empty: {comments_entry}"
);
}
/// GitLab counterpart of [`test_switch_prs_dry_run_github`], covering the
/// `fetch_gitlab` / `parse_gitlab_mrs` / `gitlab_mr_status` path via a mocked
/// `glab mr list`.
#[rstest]
fn test_switch_prs_dry_run_gitlab(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"gitlab\"\n");
let mr_json = r#"[{"iid":7,"title":"Cache the dependency graph","source_branch":"feat/cache","author":{"username":"alice"},"draft":false,"web_url":"https://gitlab.com/owner/test-repo/-/merge_requests/7","detailed_merge_status":"ci_still_running","description":"Speeds up CI by caching deps between jobs."}]"#;
let mock_bin = setup_mock_forge_list(&repo, "glab", "mr list", mr_json);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs (gitlab) failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
// Prove the fetch + parse + row-render ran (the fetch path is fail-soft and
// still exits 0): an MR row was created with the token `mr:7`, seeding its
// preview cache. See `test_switch_prs_dry_run_github` for the rationale.
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
assert!(
entries.iter().any(|e| e["branch"] == "mr:7"),
"expected an mr:7 cache entry proving the MR row rendered, got:\n{stdout}"
);
}
/// GitLab counterpart of the GitHub `_log_tab` / `_comments_tab` dry-run tests:
/// an MR row's deferred `log` and `comments` tabs load via background `glab api
/// --paginate projects/:fullpath/merge_requests/<n>/commits` / `…/notes?sort=asc`
/// calls keyed by the row's `mr:{N}` token. Both `mode:2` (Log) and `mode:7`
/// (Comments) cache entries with non-empty bytes prove `compute_pr_log` /
/// `compute_pr_comments` → `parse_gitlab_commits` / `render_gitlab_notes` →
/// cache for the GitLab forge — the half the unit tests (canned JSON straight
/// into the renderers) can't reach, pinning the endpoint/arg construction.
///
/// Both `glab api …/commits` and `…/notes` match the mock's `api --paginate`
/// compound key, so one canned response carries all fields each renderer reads
/// (`id`/`title` for commits; `body`/`author`/`created_at`/`system` for
/// notes) — serde ignores the rest.
#[rstest]
fn test_switch_prs_dry_run_gitlab_deferred_tabs(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"gitlab\"\n");
let mr_json = r#"[{"iid":7,"title":"Cache the dependency graph","source_branch":"feat/cache","author":{"username":"alice"},"draft":false,"web_url":"https://gitlab.com/owner/test-repo/-/merge_requests/7"}]"#;
let api_json = r#"[{"id":"abc12345def678900000000000000000000000a","short_id":"abc12345","title":"Cache deps between jobs","body":"Looks good.","author":{"username":"reviewer"},"created_at":"2024-12-01T00:00:00Z","system":false}]"#;
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("list.json"), mr_json).unwrap();
fs::write(mock_bin.join("api.json"), api_json).unwrap();
MockConfig::new("glab")
.version("glab version 1.0.0 (mock)")
.command("mr list", MockResponse::file("list.json"))
// `glab api --paginate <endpoint>` for both commits and notes.
.command("api --paginate", MockResponse::file("api.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs gitlab deferred tabs failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let entries = parsed["entries"].as_array().expect("entries array");
for (mode, label) in [(2, "Log"), (7, "Comments")] {
let entry = entries
.iter()
.find(|e| e["branch"] == "mr:7" && e["mode"] == mode)
.unwrap_or_else(|| panic!("no mr:7 {label} cache entry in dump:\n{stdout}"));
assert!(
entry["bytes"].as_u64().unwrap_or(0) > 0,
"{label} pane rendered non-empty: {entry}"
);
}
}
/// An empty forge list still runs `stream_open_prs` to completion, exercising
/// the empty-list branch and `forge_noun` (`_ => "PRs"` on GitHub).
#[rstest]
fn test_switch_prs_dry_run_empty(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"github\"\n");
let mock_bin = setup_mock_forge_list(&repo, "gh", "pr list", "[]");
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs (empty) failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
// Only the genuine empty-list branch stashes this exact warning (forge_noun
// = "PRs"). A missing/failed mock would stash a *different* error and still
// exit 0, so asserting the warning pins the empty path — not just exit 0.
assert!(
String::from_utf8_lossy(&output.stderr).contains("No open PRs found"),
"expected the empty-list warning on stderr, got:\n{}",
String::from_utf8_lossy(&output.stderr),
);
}
/// GitLab empty list — exercises `forge_noun`'s `GitLab => "MRs"` arm.
#[rstest]
fn test_switch_prs_dry_run_empty_gitlab(repo: TestRepo) {
repo.write_project_config("[forge]\nplatform = \"gitlab\"\n");
let mock_bin = setup_mock_forge_list(&repo, "glab", "mr list", "[]");
let mut cmd = repo.wt_command();
cmd.args(["switch", "--prs"]);
cmd.env("WORKTRUNK_PICKER_DRY_RUN", "1");
configure_mock_cli_env(&mut cmd, &mock_bin);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"dry-run --prs (empty gitlab) failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
// Only the genuine empty-list branch stashes this exact warning (forge_noun
// = "MRs"); see `test_switch_prs_dry_run_empty` for the rationale.
assert!(
String::from_utf8_lossy(&output.stderr).contains("No open MRs found"),
"expected the empty-list warning on stderr, got:\n{}",
String::from_utf8_lossy(&output.stderr),
);
}
/// Helper to set up mock gh for PR tests with custom PR response.
///
/// The response should be in `gh api repos/{owner}/{repo}/pulls/{number}` format:
/// - `head.ref`, `head.repo.owner.login`, `head.repo.name`
/// - `base.repo.owner.login`, `base.repo.name`
/// - `html_url`
fn setup_mock_gh_for_pr(repo: &TestRepo, gh_response: &str) -> std::path::PathBuf {
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("api", MockResponse::file("pr_response.json"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
mock_bin
}
/// Configure command environment for any mock CLI installed in `mock_bin`.
///
/// Sets `WORKTRUNK_TEST_MOCK_CONFIG_DIR` (so the mock playback finds its config) and
/// prepends `mock_bin` to `PATH` (so the mock binary is found before any real
/// CLI).
fn configure_mock_cli_env(cmd: &mut std::process::Command, mock_bin: &Path) {
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", mock_bin);
// Find the actual PATH var name (case-insensitive on Windows) to avoid
// creating a duplicate entry with different case.
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);
}
/// Test that --create flag conflicts with pr: syntax
#[rstest]
fn test_switch_pr_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
// Set origin URL to GitHub-style so PR resolution works
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// Mock gh to return PR info (we fetch before checking --create to show branch name)
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, 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);
});
}
/// Test same-repo PR checkout with a custom SCP-style SSH user.
#[rstest]
fn test_switch_pr_same_repo_custom_ssh_user(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create a feature branch and push it to the remote
repo.add_worktree("feature-auth");
repo.run_git(&["push", "origin", "feature-auth"]);
// Get the bare remote's actual URL before we modify origin
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
// GitHub requires this URL form when an organization enforces SSH certificates.
let github_url = "org-14957082@github.com:owner/test-repo.git";
repo.run_git(&["remote", "set-url", "origin", github_url]);
// Configure git to redirect github.com URLs to the local bare remote.
// This is necessary because:
// 1. origin must have a GitHub URL for find_remote_for_repo() to match owner/repo
// 2. But we need git fetch to actually succeed using the local bare remote
repo.run_git(&["config", &format!("url.{}.insteadOf", bare_url), github_url]);
// gh api repos/{owner}/{repo}/pulls/{number} format
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, 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);
});
}
/// Test same-repo PR with a limited fetch refspec (single-branch clone scenario).
///
/// In repos with a limited refspec (e.g., `+refs/heads/main:refs/remotes/origin/main`),
/// `git fetch origin <branch>` only updates FETCH_HEAD but doesn't create the remote
/// tracking branch. This caused `wt switch pr:101` to fail with "No branch named X".
#[rstest]
fn test_switch_pr_same_repo_limited_refspec(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create a feature branch and push it to the remote
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();
// Set origin URL to GitHub-style so find_remote_for_repo() can match owner/test-repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// Redirect github.com URLs to the local bare remote
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
// Restrict fetch refspec to only main, simulating a single-branch clone
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, 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);
});
}
/// Test same-repo PR when origin points to a different repo (no remote for PR's repo)
///
/// User scenario:
/// 1. User has origin pointing to their fork (contributor/test-repo)
/// 2. PR #101 is a same-repo PR on the upstream (owner/test-repo)
/// 3. No remote exists for owner/test-repo -> error with hint to add upstream
#[rstest]
fn test_switch_pr_same_repo_no_remote(#[from(repo_with_remote)] repo: TestRepo) {
// Set origin to point to a DIFFERENT repo than where the PR is
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
// gh api response says base.repo and head.repo are both owner/test-repo (same-repo PR)
// but origin points to contributor/test-repo (different repo)
// So find_remote_for_repo("owner", "test-repo") will fail
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, 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);
});
}
/// Test fork PR checkout (base.repo != head.repo)
#[rstest]
fn test_switch_pr_fork(#[from(repo_with_remote)] repo: TestRepo) {
// Create a PR ref on the remote that can be fetched
// First, create a commit that represents the PR head
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"]);
// Get the commit SHA and push to remote as refs/pull/42/head
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
// Push the ref to the bare remote
repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]);
// Go back to main
repo.run_git(&["checkout", "main"]);
// Get the bare remote's actual URL before we modify origin
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
// Set origin URL to GitHub-style so find_remote_for_repo() can match owner/test-repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// Configure git to redirect github.com URLs to the local bare remote.
// This is necessary because:
// 1. origin must have a GitHub URL for find_remote_for_repo() to match owner/repo
// 2. But we need git fetch to actually succeed using the local bare remote
// Git's url.<base>.insteadOf transparently rewrites the fetch URL.
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/owner/test-repo.git",
]);
// gh api repos/{owner}/{repo}/pulls/{number} format
// head.repo is the fork (contributor/test-repo), base.repo is the upstream (owner/test-repo)
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, 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);
});
}
/// `pre-start`, `post-start`, and `post-switch` hooks on PR/MR-created worktrees
/// see `pr_number` and `pr_url` in their template context. Both GitHub PRs and
/// GitLab MRs canonicalize to the same `pr_*` names — hook authors don't need
/// to branch on platform. Pre-switch fires before PR resolution and never sees
/// them.
#[rstest]
fn test_switch_pr_hooks_see_pr_vars(#[from(repo_with_remote)] repo: TestRepo) {
// Set up the same fork-PR scenario as test_switch_pr_fork: a refs/pull/42/head on the
// bare remote, origin redirected to GitHub-style URL, and `gh api` mocked.
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"]);
// The hooks live in the invoking worktree (`main`), where `wt switch pr:42`
// runs — the `pr_*` template vars come from PR resolution, independent of
// where the config lives. Each hook writes its own marker in the primary
// worktree so we can verify post-switch + post-start (background) populate
// pr_number/pr_url too. {{ repo_path }} is always the main repo's working tree.
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();
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, 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);
// post-* hooks run in the background; poll until the file appears.
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",
);
}
}
/// `wt switch pr:N` resolves `post-start` etc. from the **invoking** worktree's
/// `.config/wt.toml` — not the PR's own committed config, even though the new
/// worktree is a checkout of the PR head. The approval prompt lists those same
/// invoking-worktree hooks, so the prompt can't diverge from what runs. The PR
/// commit here carries a *different* hook that must never run.
#[rstest]
fn test_switch_pr_reads_invoking_worktree_config(#[from(repo_with_remote)] repo: TestRepo) {
// Fork-PR scenario (mirrors `test_switch_pr_hooks_see_pr_vars`): a
// refs/pull/42/head on the bare remote, origin redirected to a GitHub URL,
// `gh api` mocked. The PR commit carries its own `.config/wt.toml`.
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")]);
// Back on `main`: `pr-source` branched off before the PR commit, so
// `git checkout main` drops the PR's `.config/wt.toml` from the working tree.
repo.run_git(&["checkout", "main"]);
// The invoking worktree (`main`) carries its own — uncommitted — hook. This
// is the only `.config/wt.toml` on disk here, and the one `wt switch pr:42`
// must resolve against.
repo.write_project_config(
r#"post-start = "echo INVOKING-HOOK-RAN > {{ repo_path }}/invoking-hook-marker.txt""#,
);
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, gh_response);
// (a) Without `--yes`: the approval prompt lists the *invoking* worktree's
// hook (never the PR's), then bails (stdin is not a TTY in tests).
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("INVOKING-HOOK-RAN"),
"approval prompt should list the invoking worktree's post-start hook; stderr:\n{stderr}"
);
assert!(
!stderr.contains("PR-CONFIG-HOOK-RAN"),
"the PR ref's committed hook must not appear in the prompt; stderr:\n{stderr}"
);
assert!(
!output.status.success(),
"non-interactive approval should bail without running the hook"
);
assert!(
!repo.root_path().join("invoking-hook-marker.txt").exists(),
"a declined approval must not run the hook"
);
// (b) With `--yes`: the invoking worktree's `post-start` runs; the PR's
// committed hook never does.
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("invoking-hook-marker.txt");
wait_for_file_content(&marker);
assert_eq!(
fs::read_to_string(&marker).unwrap().trim(),
"INVOKING-HOOK-RAN",
"post-start should run from the invoking worktree's config"
);
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!repo.root_path().join("pr-hook-marker.txt").exists(),
"the PR ref's committed config must not be consulted"
);
}
/// Test fork PR when origin points to fork (no remote for base repo)
///
/// User scenario:
/// 1. User forked upstream-owner/repo to contributor/repo
/// 2. User cloned their fork, so origin points to contributor/repo
/// 3. User tries to checkout PR from upstream-owner/repo
/// 4. No remote exists for the base repo -> error with hint to add upstream
#[rstest]
fn test_switch_pr_fork_no_upstream_remote(#[from(repo_with_remote)] repo: TestRepo) {
// Set origin to point to the FORK (contributor's repo), not the base repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
// gh api response says base.repo is owner/test-repo (the upstream)
// but origin points to contributor/test-repo (the fork)
// So find_remote_for_repo("owner", "test-repo") will fail
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, 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);
});
}
/// Test fork PR when origin points to fork but `gh repo set-default` is configured
///
/// User scenario:
/// 1. User forked owner/test-repo to contributor/test-repo
/// 2. User cloned their fork, so origin = contributor/test-repo
/// 3. User added upstream remote pointing to owner/test-repo
/// 4. User ran `gh repo set-default owner/test-repo`
/// 5. User runs `wt switch pr:42` where the PR is on owner/test-repo
/// 6. Worktrunk queries the gh default repo instead of origin → success
#[rstest]
fn test_switch_pr_fork_gh_default_repo(#[from(repo_with_remote)] repo: TestRepo) {
// Create a PR ref on the remote
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"]);
// Get the bare remote's actual URL before modifying remotes
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
// Set origin to the FORK (contributor's repo) — this is the bug scenario
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/contributor/test-repo.git",
]);
// Add upstream remote pointing to the parent repo
repo.run_git(&[
"remote",
"add",
"upstream",
"https://github.com/owner/test-repo.git",
]);
// Redirect both GitHub URLs to the local bare remote for git operations
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",
]);
// Cross-repo PR: head is contributor's fork, base is owner's repo
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);
});
}
/// Test error when PR is not found
#[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();
// Configure gh api to return error for PR not found (JSON on stdout, human-readable on stderr)
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);
});
}
/// A 404 is the one GitHub failure worded by us rather than forwarded from
/// `gh`, because it answers about an owner/repo we chose — so the message has to
/// name where that choice came from. With `gh repo set-default` configured, that
/// is the default, and the remedy is to check it rather than the remotes.
#[rstest]
fn test_switch_pr_not_found_gh_default(#[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();
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"repo set-default --view",
MockResponse::output("owner/other-repo\n"),
)
.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_gh_default", cmd);
});
}
/// Regression: when the GitHub remote is *non-primary* (origin is GitLab,
/// `upstream` is GitHub), `wt switch pr:N` must derive owner/repo from the
/// GitHub remote, not the primary. The mock answers only the upstream's API
/// path; the old code queried the gitlab origin's path and hit `_default`.
#[rstest]
fn test_switch_pr_uses_nonprimary_github_remote(#[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();
// origin is GitLab (non-GitHub) — must NOT be used for the gh api path.
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/glowner/glrepo.git",
]);
// GitHub lives on a non-primary remote.
repo.run_git(&[
"remote",
"add",
"upstream",
"https://github.com/upowner/uprepo.git",
]);
// Redirect the GitHub URL to the local bare remote so fetch works.
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://github.com/upowner/uprepo.git",
]);
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(
mock_bin.join("pr_response.json"),
r#"{
"title": "Fix auth",
"user": {"login": "alice"},
"state": "open",
"draft": false,
"head": {"ref": "feature-auth", "repo": {"name": "uprepo", "owner": {"login": "upowner"}}},
"base": {"ref": "main", "repo": {"name": "uprepo", "owner": {"login": "upowner"}}},
"html_url": "https://github.com/upowner/uprepo/pull/77"
}"#,
)
.unwrap();
// Only answer the *upstream* repo's API path. If owner/repo were derived
// from the gitlab origin, the path would differ and hit `_default` (exit 1).
MockConfig::new("gh")
.version("gh version 2.0.0 (mock)")
.command(
"api repos/upowner/uprepo/pulls/77",
MockResponse::file("pr_response.json"),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let output = {
let mut cmd = repo.wt_command();
cmd.args(["switch", "pr:77"]);
configure_mock_cli_env(&mut cmd, &mock_bin);
cmd.output().unwrap()
};
assert!(
output.status.success(),
"switch pr:77 must resolve via the non-primary GitHub remote; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
/// Test error when fork was deleted (head.repo is null)
#[rstest]
fn test_switch_pr_deleted_fork(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// gh api repos/{owner}/{repo}/pulls/{number} format with null head.repo
// This happens when the fork that the PR was opened from has been deleted
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, 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);
});
}
/// Test that --base flag conflicts with pr: syntax
#[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);
});
}
/// `wt switch --create X --base pr:N` resolves the PR to its head branch for
/// a same-repo PR, fetching the branch so it's usable as the base.
#[rstest]
fn test_switch_base_pr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create the PR source branch on the remote so we have something to fetch
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();
// Rewrite origin so find_remote_for_repo matches owner/test-repo
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, 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);
});
}
/// Reproduction for #2497: `wt switch --create X --base pr:N` should set the
/// new local branch's upstream to the PR's source branch on the remote, so
/// `git push` from the new worktree pushes back to the PR rather than failing
/// with "no upstream branch".
#[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, &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"
);
}
/// `wt switch --create X --base pr:N` resolves a fork PR to its head commit
/// SHA via refs/pull/N/head without creating a tracking branch.
#[rstest]
fn test_switch_base_pr_fork(#[from(repo_with_remote)] repo: TestRepo) {
// Create a PR head commit and push it as refs/pull/42/head on the remote
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",
]);
// head.repo differs from base.repo → fork PR
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, gh_response);
let mut settings = setup_snapshot_settings(&repo);
// Fork base resolves to a commit SHA (no tracking branch created). Mask it
// so the snapshot is stable across the test repo's non-deterministic SHAs.
// Can't use \b because ANSI escape characters aren't word boundaries.
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);
});
// The PR branch name must NOT have been created as a local branch — we
// just based `my-work` on the PR's head commit.
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}"
);
}
/// `wt switch --create X --base mr:N` resolves a same-repo MR to its source
/// branch, fetching it so the base is usable.
#[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, 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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_base_mr_same_repo", cmd);
});
}
/// `--base pr:N` is ignored when `--create` is absent: we warn and do NOT
/// hit the network. `gh` is intentionally not mocked — if the resolver ran,
/// the command would fail with "gh: command not found".
#[rstest]
fn test_switch_base_pr_without_create(#[from(repo_with_remote)] mut repo: TestRepo) {
// A branch that already exists so `wt switch` can succeed without --create
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);
});
}
/// Test fork PR where branch already exists and tracks same PR (should reuse)
#[rstest]
fn test_switch_pr_fork_existing_same_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// First, manually create the branch with correct tracking config
// Branch name matches headRefName (no owner prefix) so git push works
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",
]);
// gh api repos/{owner}/{repo}/pulls/{number} format
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, 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);
});
}
/// Test fork PR where branch already exists but tracks different PR
/// Uses prefixed branch name `contributor/feature-fix` to avoid conflict
#[rstest]
fn test_switch_pr_fork_existing_different_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// Create a PR ref on the remote
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"]);
// Create the branch with tracking config for a DIFFERENT PR
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", // Different PR!
]);
// Set up GitHub URL and redirect (like test_switch_pr_fork)
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",
]);
// gh api repos/{owner}/{repo}/pulls/{number} format
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, 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);
});
}
/// Test fork PR where branch already exists with the same PR ref path but the
/// wrong remote configured.
///
/// This branch should not be reused because push/pull would be wired to the
/// wrong repository.
#[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");
// Create a PR ref on the real PR remote.
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"]);
// Create the branch with a stale tracking configuration pointing at a
// different remote that happens to use the same PR ref path.
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",
]);
// Set up GitHub URL and redirect (like test_switch_pr_fork).
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, 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}",
);
}
/// Test fork PR where branch exists but has no tracking config
/// Uses prefixed branch name `contributor/feature-fix` to avoid conflict
#[rstest]
fn test_switch_pr_fork_existing_no_tracking(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// Create a PR ref on the remote
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"]);
// Create the branch without any tracking config
let branch_name = "feature-fix";
repo.run_git(&["branch", branch_name, "main"]);
// No config set - branch exists but doesn't track anything
// Set up GitHub URL and redirect (like test_switch_pr_fork)
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",
]);
// gh api repos/{owner}/{repo}/pulls/{number} format
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, 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);
});
}
/// Test fork PR where prefixed branch already exists and tracks the same PR
/// Should reuse the existing prefixed branch
#[rstest]
fn test_switch_pr_fork_prefixed_exists_same_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// Create the unprefixed branch (simulating existing local branch)
repo.run_git(&["branch", "feature-fix", "main"]);
// Create the prefixed branch with tracking config for THIS PR
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", // Same PR
]);
// Create the worktree for the prefixed branch
// Use "repo." prefix to match the test repo's directory naming convention
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,
]);
// Set up GitHub URL
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, 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);
});
}
/// Test fork PR where prefixed branch exists but tracks different PR (should error)
#[rstest]
fn test_switch_pr_fork_prefixed_exists_different_pr(#[from(repo_with_remote)] repo: TestRepo) {
set_github_remote_url(&repo);
// Create the unprefixed branch (simulating existing local branch)
repo.run_git(&["branch", "feature-fix", "main"]);
// Create the prefixed branch with tracking config for a DIFFERENT PR
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", // Different PR!
]);
// Set up GitHub URL
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, 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);
});
}
/// Test pr: when gh is not authenticated
#[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();
// Configure gh api to return auth error (JSON on stdout, human-readable on stderr)
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);
});
}
/// Test pr: when hitting GitHub rate limit
#[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();
// Configure gh api to return rate limit error (JSON on stdout, human-readable on stderr)
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);
});
}
/// Test pr: when gh returns invalid JSON
#[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();
// Configure gh api to return invalid JSON
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);
});
}
/// Test pr: when network error occurs
#[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();
// Configure gh api to return network error (no JSON, just stderr for network failures)
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);
});
}
/// Test pr: when gh returns unknown error
#[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();
// Configure gh api to return an unrecognized multi-line error
// (realistic errors from gh often include context on multiple lines)
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);
});
}
/// Test pr: when PR has empty branch name
#[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();
// Configure gh to return valid JSON but with empty branch name (head.ref is "")
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);
});
}
// ============================================================================
// PR Syntax Tests on Gitea remotes
//
// These exercise `pr:<N>` against a Gitea remote (host detection picks the
// `tea` provider; provider selection is in choose_pr_provider). The remote
// URLs use `gitea.example.com` so `GitRemoteUrl::is_gitea()` matches and the
// ambiguous fallback is skipped — the runtime calls only the mock `tea`,
// not real `gh`.
// ============================================================================
/// Set up mock `tea` answering every `tea api --include` with `status` on
/// stderr and `body` on stdout — the two streams a real `tea` writes for one
/// HTTP response.
///
/// The status is what the Gitea provider classifies on, so every test states
/// the one it stands for rather than leaving it implied. Returns the mock bin
/// directory; pass it to `configure_mock_cli_env`.
fn setup_mock_tea(repo: &TestRepo, status: &str, body: &str) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("tea_pr_response.json"), body).unwrap();
MockConfig::new("tea")
.version("tea version development (mock)")
.command(
"api",
MockResponse::file("tea_pr_response.json").with_stderr(&tea_api_include_stderr(status)),
)
.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, "200 OK", 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, "200 OK", 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, "200 OK", 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);
});
}
/// A missing PR reaches the user as Gitea's own message.
///
/// `tea api` exits 0 for every HTTP response, so the 404 arrives as a
/// successful spawn whose stdout carries Gitea's `APIError` body rather than
/// the PR — the status line, not the exit code, is what says the request
/// failed.
#[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 = setup_mock_tea(
&repo,
"404 Not Found",
r#"{"errors":null,"message":"pull request does not exist [index: 9999]","url":"https://gitea.example.com/api/swagger"}"#,
);
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);
});
}
/// `forge.platform = "gitea"` selects the Gitea provider directly, even when
/// the remote URL doesn't look like Gitea (no ambiguous two-provider fallback).
#[rstest]
fn test_switch_pr_gitea_forge_platform(#[from(repo_with_remote)] repo: TestRepo) {
// Non-Gitea-looking URL — without `forge.platform` set we'd hit the ambiguous path.
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, "200 OK", 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);
});
}
/// `forge.platform = "gitlab"` with `pr:` should bail and tell the user to use
/// `mr:` instead — the platform is incompatible with the syntax.
#[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);
});
}
/// An invalid `forge.platform` value bails with a clear error listing the
/// accepted values, rather than silently routing somewhere via fall-through.
#[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);
});
}
/// Malformed project config must fail closed before `pr:` provider fallback.
/// Otherwise an intended `forge.platform` override on an opaque/self-hosted
/// remote can be ignored and route to the wrong CLI.
#[rstest]
fn test_switch_pr_malformed_project_config_bails_before_provider_selection(
#[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 mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).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::stderr("GH provider fallback was invoked\n").with_exit_code(42),
)
.command("_default", MockResponse::exit(42))
.write(&mock_bin);
let output = {
let mut cmd = repo.wt_command();
cmd.args(["switch", "pr:101"]);
configure_mock_cli_env(&mut cmd, &mock_bin);
cmd.output().unwrap()
};
assert!(
!output.status.success(),
"switch should fail on malformed project config"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Failed to load project config"),
"expected project-config load error, got:\n{stderr}"
);
assert!(
stderr.contains("failed to parse"),
"expected TOML parse diagnostic, got:\n{stderr}"
);
assert!(
!stderr.contains("GH provider fallback was invoked"),
"provider fallback should not run after malformed project config:\n{stderr}"
);
}
/// With no parseable remote URL, dispatch defaults to GitHub. Without `gh`
/// installed the GitHub provider bails with the install hint — a single,
/// readable error rather than a wrapped two-provider message.
#[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);
});
}
/// Detected GitLab remote with `pr:` should bail and tell the user to use `mr:`.
#[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);
});
}
/// Self-hosted forge whose hostname doesn't match any URL detection rule, but
/// `tea` has a login configured for it (via `tea login add`) — dispatch
/// inspects tea's config file and routes to Gitea.
#[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, "200 OK", 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);
});
}
/// Self-hosted forge with a non-distinctive hostname (`forge.example.com`)
/// and neither `gh` nor `tea` configured for it — dispatch defaults to
/// GitHub. The mock `gh api` succeeds, so the PR is checked out and the
/// user never sees a wrapped error.
#[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, 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);
});
}
/// Gitea API returns malformed JSON — covers the JSON parse error path
/// in `gitea::fetch_pr_info`.
#[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, "200 OK", "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);
});
}
/// `tea` itself failing — never reaching the API — is the one case that
/// exits non-zero, and it surfaces `tea`'s own stderr via `cli_api_error`.
#[rstest]
fn test_switch_pr_gitea_request_failed(#[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();
MockConfig::new("tea")
.version("tea version development (mock)")
.command(
"api",
MockResponse::stderr(
"Error: request failed: Get \"https://gitea.example.com/api/v1/repos/owner/test-repo/pulls/101\": dial tcp: connection refused\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_request_failed", cmd);
});
}
/// Gitea API returns a PR with no source branch (label and ref both empty)
/// — exercises the `extract_source_branch` failure path in `fetch_pr_info`.
#[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, "200 OK", 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);
});
}
/// Gitea API returns a PR whose head repo is null (the fork has been deleted).
/// Covers the `head_repo` `ok_or_else` path in `gitea::fetch_pr_info`.
#[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, "200 OK", 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);
});
}
/// An unauthenticated request reaches the user as Gitea's own message, which
/// names the remedy a paraphrase would have restated.
#[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 = setup_mock_tea(
&repo,
"401 Unauthorized",
r#"{"errors":null,"message":"token is required","url":"https://gitea.example.com/api/swagger"}"#,
);
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);
});
}
/// A PR the token can see but not read reaches the user as Gitea's own
/// message — the same path as the 404 and 401, which is the point: `tea`
/// hands over one body shape for every failed request.
#[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 = setup_mock_tea(
&repo,
"403 Forbidden",
r#"{"errors":null,"message":"user does not have permission to read this repository","url":"https://gitea.example.com/api/swagger"}"#,
);
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);
});
}
/// A 500 reaches the user as an API error, not as a parse failure. Gitea
/// blanks the message of a 500 in production unless the token belongs to an
/// admin, so the body arrives as the `APIError` envelope with nothing in it —
/// the status line still says the request failed, and the message says why
/// there's no more to report.
#[rstest]
fn test_switch_pr_gitea_blank_error_message(#[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,
"500 Internal Server Error",
r#"{"errors":null,"message":"","url":"https://gitea.example.com/api/swagger"}"#,
);
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_blank_error_message", cmd);
});
}
/// A `tea` that exits 0 having written no status line never reached the API,
/// so its stdout is not the PR however well-formed it looks. Real `tea` can't
/// produce this — `--include` prints the moment the response does — which is
/// exactly why the backstop is here rather than a fall-through: without it the
/// body would be read as the resource, the failure the status line exists to
/// prevent.
#[rstest]
fn test_switch_pr_gitea_no_status_line(#[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();
// A complete PR body, so nothing but the absent status line can reject it.
MockConfig::new("tea")
.version("tea version development (mock)")
.command(
"api",
MockResponse::output(
r#"{
"title": "Fix login",
"user": {"login": "alice"},
"state": "open",
"head": {"label": "feature", "ref": "feature",
"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"
}"#,
),
)
.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_no_status_line", cmd);
});
}
/// A body written in front of Gitea — a reverse proxy's error page — is an API
/// error too, on the strength of the status alone. Nothing in the body says so,
/// which is what separates this from the blanked 500 above: that one at least
/// arrives in Gitea's own envelope, while this is not JSON at all. Reading the
/// body for the answer would send it to the resource parse and blame a Gitea
/// API change for a gateway that never reached Gitea.
#[rstest]
fn test_switch_pr_gitea_proxy_error_page(#[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,
"502 Bad Gateway",
"<html><head><title>502 Bad Gateway</title></head></html>",
);
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_proxy_error_page", cmd);
});
}
// ============================================================================
// PR Syntax Tests on Azure DevOps remotes
//
// These exercise `pr:<N>` against an Azure DevOps remote. Host detection picks
// the `az` provider (provider selection is in choose_pr_provider). The remote
// URLs use `dev.azure.com` / `*.visualstudio.com` so `GitRemoteUrl::is_azure_devops()`
// matches and the ambiguous fallback is skipped — the runtime calls only the
// mock `az`, not real `gh`.
// ============================================================================
/// `az extension list --output json` with the azure-devops extension present.
///
/// Every `az repos pr show` failure asks this, so an az failure that is *not*
/// a missing extension has to answer it — otherwise the test proves nothing
/// about which of the two errors the user sees.
const AZ_EXTENSION_INSTALLED: &str = r#"[{"name": "azure-devops", "version": "1.0.0"}]"#;
/// Helper to set up mock `az` for Azure DevOps PR tests with a custom
/// `az repos pr show` response.
///
/// Returns the path to the mock bin directory; pass it to
/// `configure_mock_cli_env`.
fn setup_mock_az(repo: &TestRepo, az_pr_response: &str) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("az_pr_response.json"), az_pr_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
}
/// Point `origin` at an Azure DevOps URL and redirect that URL to the local
/// bare remote via `url.insteadOf`, so `git fetch` still works.
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, 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, 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_project_name_with_spaces(#[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/project%20with%20spaces/_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": "project with spaces"},
"webUrl": "https://dev.azure.com/myorg/project%20with%20spaces/_git/test-repo"
},
"forkSource": null
}"#;
let mock_bin = setup_mock_az(&repo, 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_project_name_with_spaces", cmd);
});
}
/// A full Azure DevOps PR web URL passed to `wt switch` resolves the same as the
/// `pr:N` shortcut: the URL normalises to `pr:101` (shape-based `parse_ref_url`),
/// dispatch selects `AzureDevOpsProvider`, and the worktree is created. The
/// snapshot should match `switch_pr_azure_same_repo`, since both forms converge
/// on the same shortcut.
#[rstest]
fn test_switch_pr_azure_web_url(#[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, az_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(
&repo,
"switch",
&["https://dev.azure.com/myorg/myproject/_git/test-repo/pullrequest/101"],
None,
);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_pr_azure_web_url", cmd);
});
}
/// Regression: an Azure PR whose `sourceRefName` is just `refs/heads/`
/// (empty branch after stripping) must fail at the provider boundary with a
/// clear message — matching GitHub/GitLab/Gitea — not produce a confusing
/// downstream git/path error.
#[rstest]
fn test_switch_pr_azure_empty_source_branch(#[from(repo_with_remote)] repo: TestRepo) {
set_azure_remote_url(
&repo,
"https://dev.azure.com/myorg/myproject/_git/test-repo",
);
let az_response = r#"{
"title": "Broken PR",
"createdBy": {"uniqueName": "alice@example.com"},
"status": "active",
"isDraft": false,
"sourceRefName": "refs/heads/",
"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, az_response);
let output = {
let mut cmd = repo.wt_command();
cmd.args(["switch", "pr:101"]);
configure_mock_cli_env(&mut cmd, &mock_bin);
cmd.output().unwrap()
};
assert!(
!output.status.success(),
"switch must fail on an empty Azure source branch"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("empty branch name"),
"expected a clear empty-branch diagnostic, got:\n{stderr}"
);
}
/// Legacy `*.visualstudio.com` remotes encode the org in the hostname — exercises
/// the `*.visualstudio.com` branches of the Azure URL helpers end to end.
#[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, 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, 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);
});
}
/// A missing PR reaches the user as the `TF401174` line `az` printed.
///
/// Once the extension question is settled, `azure::fetch_pr_info` classifies
/// nothing: `az` has no error channel but prose on stderr, so worktrunk
/// reports that prose rather than guessing a cause from it. Each azure error
/// test below pins the tool's own words surviving to the terminal.
#[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();
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(
"extension list",
MockResponse::output(AZ_EXTENSION_INSTALLED),
)
.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);
});
}
/// `az` not installed: with an Azure remote, dispatch routes to the Azure
/// provider, which bails with the install hint when `az` isn't on PATH.
#[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);
});
}
/// `forge.platform = "azure-devops"` selects the Azure provider directly, even
/// when the remote URL doesn't look like Azure DevOps.
#[rstest]
fn test_switch_pr_azure_forge_platform(#[from(repo_with_remote)] repo: TestRepo) {
// Non-Azure-looking URL — without `forge.platform` set we'd default to GitHub.
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, 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);
});
}
/// `az repos pr show` returning malformed JSON hits the parse-error path
/// in `azure::fetch_pr_info`.
#[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, "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);
});
}
/// An `az` that can't answer the extension question still reports its own
/// error, not a missing extension.
///
/// Here `az extension list` fails alongside the generic `az repos pr show`
/// failure, so the check comes back unanswered — and an unanswered check must
/// not turn into an accusation the user would act on.
#[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();
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("extension list", MockResponse::exit(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);
});
}
/// An unauthenticated `az` reaches the user as its own "run 'az login'" line,
/// which already names the remedy a paraphrase would have restated.
#[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();
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(
"extension list",
MockResponse::output(AZ_EXTENSION_INSTALLED),
)
.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);
});
}
/// A missing `azure-devops` extension reaches the user as the install command,
/// which `az` names the need for but never spells out.
///
/// The whole `repos` command group lives in that extension, so `az extension
/// list` decides this from JSON — `az`'s "is misspelled or not recognized"
/// prose is never consulted, and never reaches the user.
#[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();
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("extension list", MockResponse::output("[]"))
.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);
});
}
/// When the API response has no `webUrl` and no local Azure remote is
/// configured, `fetch_pr_info` can't determine the org/host and bails with a
/// clear error rather than inventing one.
#[rstest]
fn test_switch_pr_azure_org_undeterminable(#[from(repo_with_remote)] repo: TestRepo) {
// Non-Azure remote → detect_azure_target returns None.
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();
// Response omits `repository.webUrl` → parse_web_url returns None.
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, 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);
});
}
// ============================================================================
// MR Syntax Tests (mr:<number>) - GitLab
// ============================================================================
/// Helper to set up mock glab for MR tests with custom MR response.
///
/// The response should be in `glab api projects/:id/merge_requests/<number>` format:
/// - `source_branch`, `source_project_id`, `target_project_id`
/// - `web_url`
fn setup_mock_glab_for_mr(repo: &TestRepo, glab_response: &str) -> std::path::PathBuf {
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
fs::write(mock_bin.join("mr_response.json"), glab_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
}
/// Test that --create flag conflicts with mr: syntax
#[rstest]
fn test_switch_mr_create_conflict(#[from(repo_with_remote)] repo: TestRepo) {
// Mock glab to return MR info (we fetch before checking --create to show branch name)
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_create_conflict", cmd);
});
}
/// Test that --base flag conflicts with mr: syntax
#[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);
});
}
/// Test same-repo MR checkout (source_project_id == target_project_id)
#[rstest]
fn test_switch_mr_same_repo(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create a feature branch and push it
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();
// Set origin URL to GitLab-style so find_remote_for_repo() can match owner/test-repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
// Redirect gitlab.com URLs to the local bare remote
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
// glab api projects/:id/merge_requests/<number> format
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo", cmd);
});
}
/// Test same-repo MR with a limited fetch refspec (single-branch clone scenario).
///
/// In repos with a limited refspec (e.g., `+refs/heads/main:refs/remotes/origin/main`),
/// `git fetch origin <branch>` only updates FETCH_HEAD but doesn't create the remote
/// tracking branch. This caused `wt switch mr:101` to fail with "No branch named X".
#[rstest]
fn test_switch_mr_same_repo_limited_refspec(#[from(repo_with_remote)] mut repo: TestRepo) {
// Create a feature branch and push it to the remote
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();
// Set origin URL to GitLab-style so find_remote_for_repo() can match owner/test-repo
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
// Redirect gitlab.com URLs to the local bare remote
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
// Restrict fetch refspec to only main, simulating a single-branch clone
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo_limited_refspec", cmd);
});
}
/// Test same-repo MR when origin points to a different repo (no remote for MR's repo)
///
/// User scenario:
/// 1. User has origin pointing to their fork (contributor/test-repo)
/// 2. MR !101 is a same-repo MR on the upstream (owner/test-repo)
/// 3. No remote exists for owner/test-repo -> error with hint to add upstream
#[rstest]
fn test_switch_mr_same_repo_no_remote(#[from(repo_with_remote)] repo: TestRepo) {
// Set origin to point to a DIFFERENT repo than where the MR is
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_same_repo_no_remote", cmd);
});
}
/// Test same-repo MR with malformed web_url (missing /-/ separator)
#[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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_malformed_web_url", cmd);
});
}
/// Test same-repo MR with unparsable project URL (has /-/ but no owner/repo)
#[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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_malformed_web_url_no_project", cmd);
});
}
/// Test error when MR is not found
#[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();
// Configure glab api to return 404 error (JSON on stdout, human-readable on
// stderr — glab formats the API's own message as `glab: <message> (HTTP N)`)
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"404 Not found"}"#)
.with_stderr("glab: 404 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", &["mr:9999"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_not_found", cmd);
});
}
/// Test mr: when glab is not authenticated
#[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();
// Configure glab api to return 401 error (JSON on stdout, human-readable on
// stderr — the shape a real `glab api` produces for a rejected token)
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api",
MockResponse::output(r#"{"message":"401 Unauthorized"}"#)
.with_stderr("glab: 401 Unauthorized (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", &["mr:101"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_not_authenticated", cmd);
});
}
/// Test mr: when glab returns invalid JSON
#[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();
// Configure glab api to return invalid JSON
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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_invalid_json", cmd);
});
}
/// Test mr: when MR has empty branch name
#[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();
// Configure glab api to return valid JSON but with empty branch name
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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_empty_branch", cmd);
});
}
/// Test fork MR checkout (source_project_id != target_project_id)
#[rstest]
fn test_switch_mr_fork(#[from(repo_with_remote)] repo: TestRepo) {
// Create a MR ref on the remote that can be fetched
// First, create a commit that represents the MR head
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"]);
// Get the commit SHA and push to remote as refs/merge-requests/42/head
let commit_sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let sha = String::from_utf8_lossy(&commit_sha.stdout)
.trim()
.to_string();
// Push the ref to the bare remote
repo.run_git(&[
"push",
"origin",
&format!("{}:refs/merge-requests/42/head", sha),
]);
// Go back to main
repo.run_git(&["checkout", "main"]);
// Get the bare remote's actual URL before we modify origin
let bare_url = String::from_utf8_lossy(
&repo
.git_command()
.args(["config", "remote.origin.url"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
// Set origin URL to GitLab-style so find_remote_by_url() can match
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
// Configure git to redirect gitlab.com URLs to the local bare remote.
// This is necessary because:
// 1. origin must have a GitLab URL for find_remote_by_url() to match target project
// 2. But we need git fetch to actually succeed using the local bare remote
// Git's url.<base>.insteadOf transparently rewrites the fetch URL.
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
// Set up mock glab with separate responses for MR API and project APIs.
// Mock playback supports compound keys like "api projects/456" to match
// different API paths.
let mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
// MR API response (no nested project data - that comes from separate calls)
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"
}"#;
// Source project (fork) API response
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"
}"#;
// Target project (upstream) API response
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)")
// Compound key: "api projects/:id/merge_requests/42"
.command(
"api projects/:id/merge_requests/42",
MockResponse::output(mr_response),
)
// Compound key: "api projects/456" (source project)
.command(
"api projects/456",
MockResponse::output(source_project_response),
)
// Compound key: "api projects/123" (target project)
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork", cmd);
});
}
/// A failing `glab api projects/<id>` surfaces glab's own verdict.
///
/// The MR call already forwards it (`fetch_mr_info`); the deferred project
/// call is the other half of the same fork path, and a bare "Failed to fetch
/// project 456" leaves the reader unable to tell a 401 from a 404.
#[rstest]
fn test_switch_mr_fork_project_fetch_error(#[from(repo_with_remote)] 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://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
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 mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api projects/:id/merge_requests/42",
MockResponse::output(mr_response),
)
// The source (fork) project lookup is denied.
.command(
"api projects/456",
MockResponse::exit(1).with_stderr("glab: 404 Project Not Found (HTTP 404)"),
)
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_project_fetch_error", cmd);
});
}
/// The other half of the role label: when the source lookup succeeds and the
/// *target* one is denied, the message names "target project 123".
///
/// Without this, changing the target call's `role` literal to `"source"` in
/// `fetch_gitlab_project_urls` leaves the suite green while telling the user
/// they're locked out of the fork when it's actually the upstream. (Swapping
/// *both* literals is already caught by the sibling above, whose snapshot
/// pins "source project 456".)
#[rstest]
fn test_switch_mr_fork_target_project_fetch_error(#[from(repo_with_remote)] 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://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
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 mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api projects/:id/merge_requests/42",
MockResponse::output(mr_response),
)
// The source (fork) project lookup succeeds ...
.command(
"api projects/456",
MockResponse::output(source_project_response),
)
// ... and the target (upstream) one is denied.
.command(
"api projects/123",
MockResponse::exit(1).with_stderr("glab: 403 Forbidden (HTTP 403)"),
)
.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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_target_project_fetch_error", cmd);
});
}
/// A `glab api projects/<id>` response that isn't the expected shape names the
/// project it came from, the same as the MR parse arm above it.
#[rstest]
fn test_switch_mr_fork_project_invalid_json(#[from(repo_with_remote)] 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://gitlab.com/owner/test-repo.git",
]);
repo.run_git(&[
"config",
&format!("url.{}.insteadOf", bare_url),
"https://gitlab.com/owner/test-repo.git",
]);
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 mock_bin = repo.root_path().join("mock-bin");
fs::create_dir_all(&mock_bin).unwrap();
MockConfig::new("glab")
.version("glab version 1.40.0 (mock)")
.command(
"api projects/:id/merge_requests/42",
MockResponse::output(mr_response),
)
// The source (fork) project lookup succeeds but answers with something
// that isn't a project object.
.command(
"api projects/456",
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:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_project_invalid_json", cmd);
});
}
/// Test fork MR checkout when branch already exists and tracks the MR
#[rstest]
fn test_switch_mr_fork_existing_branch_tracks_mr(#[from(repo_with_remote)] repo: TestRepo) {
// Create the branch that will track the MR
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"]);
// Get the commit SHA and push to remote as refs/merge-requests/42/head
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),
]);
// Configure branch to track the MR ref (as our code would set it up)
repo.run_git(&["config", "branch.feature-fix.remote", "origin"]);
repo.run_git(&[
"config",
"branch.feature-fix.merge",
"refs/merge-requests/42/head",
]);
// Go back to main
repo.run_git(&["checkout", "main"]);
// Set origin URL to GitLab-style
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",
]);
// Fork MR response (project URLs not needed since branch already exists)
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_branch_tracks_mr", cmd);
});
}
/// Test fork MR checkout when branch exists but tracks something else
#[rstest]
fn test_switch_mr_fork_existing_branch_tracks_different(#[from(repo_with_remote)] repo: TestRepo) {
// Create the branch that tracks a different ref
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"]);
// Configure branch to track a different MR
repo.run_git(&["config", "branch.feature-fix.remote", "origin"]);
repo.run_git(&[
"config",
"branch.feature-fix.merge",
"refs/merge-requests/99/head", // Different MR number
]);
// Go back to main
repo.run_git(&["checkout", "main"]);
// Set origin URL to GitLab-style
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
// Fork MR response for MR 42, but branch tracks MR 99 (error case)
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_branch_tracks_different", cmd);
});
}
/// Test fork MR checkout when branch exists but has no tracking config
#[rstest]
fn test_switch_mr_fork_existing_no_tracking(#[from(repo_with_remote)] repo: TestRepo) {
// Create the branch without any tracking config
repo.run_git(&["branch", "feature-fix", "main"]);
// No config set - branch exists but doesn't track anything
// Set origin URL to GitLab-style
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://gitlab.com/owner/test-repo.git",
]);
// Fork MR response (project URLs not needed since branch already exists)
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, glab_response);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["mr:42"], None);
configure_mock_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_fork_existing_no_tracking", cmd);
});
}
/// Test mr: with unknown glab error (falls through to general error handler)
#[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();
// Configure glab api to return an unknown error (non-JSON stderr, like network errors)
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_cli_env(&mut cmd, &mock_bin);
assert_cmd_snapshot!("switch_mr_unknown_error", cmd);
});
}
/// Set up a minimal bin directory with only git (no gh/glab).
///
/// Creates a temporary directory with a symlink to git, excluding gh/glab.
/// Returns the path to use as PATH.
/// Create a minimal bin directory with only git, excluding gh/glab.
/// Returns None on Windows where this approach doesn't work reliably.
#[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();
// Find git binary using the which crate (cross-platform)
let git_path = which::which("git").expect("git must be installed to run tests");
// Symlink git into our minimal bin directory
std::os::unix::fs::symlink(&git_path, minimal_bin.join("git")).unwrap();
Some(minimal_bin)
}
/// On Windows, git requires its entire installation directory to function,
/// so we can't easily create a minimal PATH with just git. Skip these tests.
#[cfg(windows)]
fn setup_minimal_bin_without_cli(_repo: &TestRepo) -> Option<std::path::PathBuf> {
None
}
/// Configure PATH to exclude gh/glab, keeping only git.
///
/// This simulates the "CLI not installed" scenario.
fn configure_cli_not_installed_env(cmd: &mut std::process::Command, minimal_bin: &Path) {
cmd.env("PATH", minimal_bin);
}
/// Test pr: when gh CLI is not installed
#[rstest]
fn test_switch_pr_gh_not_installed(#[from(repo_with_remote)] repo: TestRepo) {
// Set a GitHub URL so fetch_pr_info can parse owner/repo before checking for gh
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 {
// Symlinks not available (Windows without Developer Mode)
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);
});
}
/// Test mr: when glab CLI is not installed
#[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 {
// Symlinks not available (Windows without Developer Mode)
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);
});
}
/// Bug fix: switching to the current worktree (AlreadyAt) must NOT update switch history.
///
/// Previously, `wt switch foo` while already in `foo` would record `foo` as the
/// previous branch, corrupting `wt switch -` so it pointed to the current branch
/// instead of the actual previous one.
#[rstest]
fn test_switch_already_at_preserves_history(repo: TestRepo) {
// Create a feature branch with worktree
repo.run_git(&["branch", "hist-feature"]);
// Step 1: Switch from main to hist-feature (establishes history: previous=main)
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(),
);
// Step 2: Switch to hist-feature again while already there (AlreadyAt)
// This should NOT update history
snapshot_switch_from_dir(
"already_at_preserves_history_2_noop",
&repo,
&["hist-feature"],
&feature_path,
);
// Step 3: `wt switch -` should still go to main (the real previous),
// not to hist-feature (which the bug would have recorded)
snapshot_switch_from_dir(
"already_at_preserves_history_3_dash_to_main",
&repo,
&["-"],
&feature_path,
);
}
/// WORKTRUNK_FIRST_OUTPUT exits after execute_switch, before mismatch computation
/// and output rendering. Used by time-to-first-output benchmarks.
#[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)
);
// No output expected — early exit skips all rendering
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
/// Bug fix: `--base` without `--create` should warn, not error.
///
/// Previously, `--base -` was resolved (calling resolve_worktree_name) before
/// checking the `--create` flag. When there was no previous branch in history,
/// this produced "No previous branch found" instead of the expected
/// "--base flag is only used with --create, ignoring" warning.
#[rstest]
fn test_switch_base_without_create_warns_not_errors(repo: TestRepo) {
repo.run_git(&["branch", "base-test"]);
// No switch history exists, so resolving `-` would fail.
// But --base without --create should just warn and ignore the flag.
snapshot_switch(
"switch_base_without_create_warns",
&repo,
&["base-test", "--base", "-"],
);
}
/// Test that `--cd` flag overrides `[switch] cd = false` config
#[rstest]
fn test_switch_cd_flag_overrides_no_cd_config(repo: TestRepo) {
// Set up config with cd = false
repo.write_test_config(
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[switch]
cd = false
"#,
);
repo.run_git(&["branch", "cd-override-test"]);
// --cd should override the config and include cd directive
snapshot_switch(
"switch_cd_flag_overrides_config",
&repo,
&["cd-override-test", "--cd"],
);
}
/// Test that `--no-cd` flag works (explicit flag, no config)
#[rstest]
fn test_switch_no_cd_flag_explicit(repo: TestRepo) {
repo.run_git(&["branch", "no-cd-explicit"]);
// --no-cd should skip the cd directive
snapshot_switch(
"switch_no_cd_flag_explicit",
&repo,
&["no-cd-explicit", "--no-cd"],
);
}
/// A *deprecated* key passed via `--config-set` is migrated to its canonical
/// form before it is applied (`switch.no-cd` → `switch.cd`), so it takes effect
/// end-to-end instead of being dropped as an unknown field. The migrated
/// `switch.cd = false` suppresses the cd directive, so the output omits the
/// "Cannot change directory" line a cd-enabled switch prints. The migration is
/// silent: an inline override has no file for `wt config update` to rewrite, so
/// no deprecation warning appears.
#[rstest]
fn test_switch_config_set_migrates_deprecated_no_cd(repo: TestRepo) {
repo.run_git(&["branch", "dep-no-cd"]);
snapshot_switch(
"switch_config_set_migrates_deprecated_no_cd",
&repo,
&["dep-no-cd", "--config-set", "switch.no-cd = true"],
);
}
/// The `WORKTRUNK_*` env layer migrates deprecated keys the same way:
/// `WORKTRUNK__SWITCH__NO_CD=true` resolves to the deprecated `switch.no-cd`,
/// which is canonicalized to `switch.cd = false` and suppresses the cd
/// directive — the output omits the "Cannot change directory" line. Without
/// migration the env var would fall through as an unknown field and the line
/// would appear.
#[rstest]
fn test_switch_env_var_migrates_deprecated_no_cd(repo: TestRepo) {
repo.run_git(&["branch", "env-no-cd"]);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["env-no-cd"], None);
cmd.env("WORKTRUNK__SWITCH__NO_CD", "true");
assert_cmd_snapshot!("switch_env_var_migrates_deprecated_no_cd", cmd);
});
}
/// Test that worktrunk works correctly when `worktree.useRelativePaths` is enabled.
///
/// Git 2.48+ supports `worktree.useRelativePaths`, which stores relative paths in the
/// `.git` file of linked worktrees instead of absolute paths. This makes worktree
/// directories relocatable. Older Git versions may still write absolute links,
/// so this test accepts either link shape and then verifies that worktrunk's
/// path handling works.
///
/// See: https://github.com/max-sixty/worktrunk/issues/1630
#[rstest]
fn test_switch_with_relative_worktree_paths(repo: TestRepo) {
// Enable relative paths for worktrees
repo.run_git(&["config", "worktree.useRelativePaths", "true"]);
// Create a new worktree via wt switch --create
snapshot_switch(
"switch_create_relative_paths",
&repo,
&["--create", "relative-test"],
);
// Verify the .git file in the worktree contains a gitdir link. Git 2.48+
// writes a relative link when worktree.useRelativePaths is enabled; older
// versions keep an absolute link.
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();
let gitdir = git_file
.strip_prefix("gitdir: ")
.map(str::trim)
.expect("linked worktree .git file should contain gitdir pointer");
let gitdir_path = Path::new(gitdir);
assert!(
gitdir_path.is_absolute() || gitdir.starts_with("../"),
"Expected relative or absolute gitdir in .git file: {git_file}"
);
// Verify wt list shows the worktree (path resolution works)
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);
});
// Verify switching to the relative-path worktree works
snapshot_switch("switch_to_relative_paths", &repo, &["relative-test"]);
}
// -- JSON format tests --
#[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) {
// Create worktree first
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");
// created_branch should be absent for existing switches
assert!(json.get("created_branch").is_none());
}
#[rstest]
fn test_switch_format_json_already_at(mut repo: TestRepo) {
// Create worktree and switch to it
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}");
}
/// Test that `[switch] cd = false` config is respected when no flags provided
#[rstest]
fn test_switch_no_cd_config_default(repo: TestRepo) {
// Set up config with cd = false
repo.write_test_config(
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[switch]
cd = false
"#,
);
repo.run_git(&["branch", "no-cd-config-test"]);
// Without any cd flags, config should be respected (no cd directive)
snapshot_switch("switch_no_cd_config_default", &repo, &["no-cd-config-test"]);
}
/// A worktree's own path names it, including the single-component spelling —
/// `wt switch solo` where `solo/` is a worktree but no branch is called that.
///
/// Path resolution runs through `-C`, so the assertion is on the directory the
/// switch resolved to rather than on the process cwd.
#[rstest]
fn switch_by_relative_worktree_path(mut repo: TestRepo) {
let nested = repo.root_path().join("solo");
let worktree_path = repo.add_worktree_at_path("solo-branch", &nested);
for spelling in ["solo", "./solo", worktree_path.to_str().unwrap()] {
let output = repo
.wt_command()
.args(["switch", spelling, "--no-cd"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"switch {spelling} should resolve the worktree: {stderr}"
);
assert!(
stderr.contains("solo-branch"),
"switch {spelling} should name the branch checked out there: {stderr}"
);
}
}
/// The tilde form worktrunk prints paths in is a form it also accepts, so a
/// path copied out of wt's output works when the shell can't expand it.
#[cfg(unix)]
#[rstest]
fn switch_by_tilde_worktree_path(mut repo: TestRepo, temp_home: TempDir) {
let worktree_path = repo.add_worktree("feature");
// Re-home the process at the worktree's parent so the worktree is under
// `~`, which is what makes the tilde spelling reachable at all.
let home = worktree_path.parent().unwrap().to_path_buf();
drop(temp_home);
let mut cmd = repo.wt_command();
set_temp_home_env(&mut cmd, &home);
let output = cmd
.args([
"switch",
&format!("~/{}", worktree_path.file_name().unwrap().to_str().unwrap()),
"--no-cd",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success() && stderr.contains("feature"),
"a tilde-form worktree path should resolve: {stderr}"
);
}
/// `--base` names a branch, so a worktree's path stands for the branch checked
/// out there — the same rule as a merge or rebase target.
#[rstest]
fn switch_base_accepts_worktree_path(mut repo: TestRepo) {
let base_path = repo.add_worktree("base-branch");
let output = repo
.wt_command()
.args([
"switch",
"--create",
"derived",
"--base",
base_path.to_str().unwrap(),
"--no-cd",
])
.output()
.unwrap();
let raw = String::from_utf8_lossy(&output.stderr);
let stderr = raw.ansi_strip();
assert!(
output.status.success() && stderr.contains("from base-branch"),
"--base should resolve the worktree path to its branch: {stderr}"
);
}