use crate::common::{TestRepo, make_snapshot_cmd, repo};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
fn worktree_parent(repo: &TestRepo) -> std::path::PathBuf {
repo.root_path().parent().unwrap().to_path_buf()
}
#[rstest]
fn test_relocate_no_mismatches(mut repo: TestRepo) {
repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
}
#[rstest]
fn test_relocate_single_mismatch(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Worktree should be at expected path: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Old worktree path should no longer exist: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_dry_run(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--dry-run"],
None
));
assert!(
wrong_path.exists(),
"Worktree should still be at wrong path in dry run: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_locked_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
repo.run_git(&["worktree", "lock", wrong_path.to_str().unwrap()]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
assert!(
wrong_path.exists(),
"Locked worktree should not be moved: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_mixed_success_and_skip(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path1 = parent.join("wrong-location-1");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature1",
wrong_path1.to_str().unwrap(),
]);
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature2",
wrong_path2.to_str().unwrap(),
]);
repo.run_git(&["worktree", "lock", wrong_path2.to_str().unwrap()]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
let expected_path1 = parent.join("repo.feature1");
assert!(
expected_path1.exists(),
"feature1 should be at expected path: {}",
expected_path1.display()
);
assert!(
wrong_path2.exists(),
"Locked feature2 should not be moved: {}",
wrong_path2.display()
);
}
#[rstest]
fn test_relocate_target_exists(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let expected_path = parent.join("repo.feature");
fs::create_dir_all(&expected_path).unwrap();
fs::write(expected_path.join("existing-file.txt"), "existing").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
assert!(
wrong_path.exists(),
"Worktree should not be moved when target exists: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_dirty_without_commit(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
fs::write(wrong_path.join("dirty.txt"), "uncommitted changes").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
assert!(
wrong_path.exists(),
"Dirty worktree should not be moved: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_dirty_with_commit(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
fs::write(wrong_path.join("dirty.txt"), "uncommitted changes").unwrap();
let worktrunk_config = r#"
[commit.generation]
command = "cat >/dev/null && echo 'chore: auto-commit before relocate'"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--commit"],
None
));
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Worktree should be at expected path after commit: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Old worktree path should no longer exist: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_clobber_backs_up(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let expected_path = parent.join("repo.feature");
fs::create_dir_all(&expected_path).unwrap();
fs::write(expected_path.join("existing-file.txt"), "existing content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--clobber"],
None
));
assert!(
expected_path.exists(),
"Worktree should be at expected location: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Original path should no longer exist: {}",
wrong_path.display()
);
let backup_exists = fs::read_dir(&parent)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("repo.feature.bak-")
});
assert!(backup_exists, "Backup directory should exist");
}
#[rstest]
fn test_relocate_clobber_refuses_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_path.to_str().unwrap(),
]);
let expected_path = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
expected_path.to_str().unwrap(),
]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--clobber", "alpha"],
None
));
assert!(
wrong_path.exists(),
"alpha should still be at wrong location: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_specific_branch(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path1 = parent.join("wrong-location-1");
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature1",
wrong_path1.to_str().unwrap(),
]);
repo.run_git(&[
"worktree",
"add",
"-b",
"feature2",
wrong_path2.to_str().unwrap(),
]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "feature1"],
None
));
let expected_path1 = parent.join("repo.feature1");
assert!(
expected_path1.exists(),
"feature1 should be at expected path: {}",
expected_path1.display()
);
assert!(
wrong_path2.exists(),
"feature2 should still be at wrong path: {}",
wrong_path2.display()
);
}
#[rstest]
fn test_relocate_main_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
repo.run_git(&["checkout", "-b", "feature"]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Feature worktree should be created at: {}",
expected_path.display()
);
let output = repo
.git_command()
.args(["branch", "--show-current"])
.run()
.unwrap();
let current_branch = String::from_utf8_lossy(&output.stdout);
assert_eq!(
current_branch.trim(),
"main",
"Main worktree should be on default branch"
);
}
#[rstest]
fn test_relocate_swap(repo: TestRepo) {
let parent = worktree_parent(&repo);
let path_for_beta = parent.join("repo.beta");
let path_for_alpha = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
path_for_beta.to_str().unwrap(), ]);
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
path_for_alpha.to_str().unwrap(), ]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
assert!(path_for_alpha.exists(), "alpha should be at repo.alpha");
assert!(path_for_beta.exists(), "beta should be at repo.beta");
}
#[rstest]
fn test_relocate_multiple(repo: TestRepo) {
let parent = worktree_parent(&repo);
for i in 1..=5 {
let wrong_path = parent.join(format!("wrong-{i}"));
repo.run_git(&[
"worktree",
"add",
"-b",
&format!("feature-{i}"),
wrong_path.to_str().unwrap(),
]);
}
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
for i in 1..=5 {
let expected_path = parent.join(format!("repo.feature-{i}"));
assert!(
expected_path.exists(),
"feature-{i} should be at expected path: {}",
expected_path.display()
);
}
}
#[rstest]
fn test_relocate_same_target_no_panic(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path1 = parent.join("wrong-location-1");
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_path1.to_str().unwrap(),
]);
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
wrong_path2.to_str().unwrap(),
]);
let worktrunk_config = r#"
worktree-path = "{{ repo }}.shared"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "alpha", "beta"],
None
));
let shared_path = repo.root_path().join("repo.shared");
assert!(
shared_path.exists(),
"First worktree should be at shared path: {}",
shared_path.display()
);
assert!(
wrong_path1.exists() || wrong_path2.exists(),
"One worktree should remain at original location (skipped)"
);
}
#[rstest]
fn test_relocate_template_error(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let worktrunk_config = r#"
worktree-path = "{{ nonexistent_variable }}"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "feature"],
None
));
assert!(
wrong_path.exists(),
"Worktree should not be moved when template fails: {}",
wrong_path.display()
);
}
#[rstest]
fn test_relocate_main_worktree_checkout_failure_surfaces(repo: TestRepo) {
let parent = worktree_parent(&repo);
let repo_path = repo.root_path().to_path_buf();
repo.run_git(&["checkout", "-b", "feature"]);
repo.run_git(&[
"config",
"worktrunk.default-branch",
"nonexistent-branch-xyz",
]);
let output = repo
.wt_command()
.args(["step", "relocate"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"relocate must fail when checkout of default branch fails; \
stdout: {stdout}\nstderr: {stderr}"
);
assert!(
!stderr.contains("Relocated"),
"relocate must not claim success after a failed checkout; \
stdout: {stdout}\nstderr: {stderr}"
);
assert!(
repo_path.exists(),
"main worktree path should still exist: {}",
repo_path.display()
);
let expected_path = parent.join("repo.feature");
assert!(
!expected_path.exists(),
"relocate must not create the new worktree path after checkout \
failure: {}",
expected_path.display()
);
let branch_output = repo
.git_command()
.args(["branch", "--show-current"])
.run()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&branch_output.stdout).trim(),
"feature",
"main worktree branch should be unchanged after failed checkout"
);
}
#[rstest]
fn test_relocate_empty_default_branch(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
repo.run_git(&["branch", "-m", "main", "trunk-a"]);
repo.run_git(&["remote", "remove", "origin"]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
}