use crate::common::{
BareRepoTest, TestRepo, TestRepoBase, configure_directive_file, directive_file,
make_snapshot_cmd, repo, repo_with_remote, setup_snapshot_settings,
setup_temp_snapshot_settings, wt_command,
};
use ansi_str::AnsiStr;
use insta::assert_snapshot;
use insta_cmd::assert_cmd_snapshot;
use path_slash::PathExt as _;
use rstest::rstest;
use std::time::Duration;
#[rstest]
fn test_remove_already_on_default(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &[], None));
}
#[rstest]
fn test_remove_switch_to_default(repo: TestRepo) {
repo.run_git(&["switch", "-c", "feature"]);
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &[], None));
}
#[rstest]
fn test_remove_from_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-wt");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_internal_mode(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-internal");
let (directive_path, _guard) = directive_file();
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "remove", &[], Some(&worktree_path));
configure_directive_file(&mut cmd, &directive_path);
cmd
});
}
#[rstest]
fn test_remove_as_git_subcommand(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-git-subcmd");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "remove", &[], Some(&worktree_path));
cmd.env("GIT_EXEC_PATH", "/usr/lib/git-core");
assert_cmd_snapshot!("remove_as_git_subcommand", cmd);
});
}
#[rstest]
fn test_remove_dirty_working_tree(repo: TestRepo) {
std::fs::write(repo.root_path().join("dirty.txt"), "uncommitted changes").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &[], None));
}
#[rstest]
fn test_remove_locked_worktree(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("locked-feature");
repo.lock_worktree("locked-feature", Some("Testing lock"));
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["locked-feature"],
None
));
}
#[rstest]
fn test_remove_locked_worktree_no_reason(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("locked-no-reason");
repo.lock_worktree("locked-no-reason", None);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["locked-no-reason"],
None
));
}
#[rstest]
fn test_remove_locked_current_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("locked-current");
repo.lock_worktree("locked-current", Some("Do not remove"));
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_locked_detached_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("locked-detached");
repo.detach_head_in_worktree("locked-detached");
repo.lock_worktree("locked-detached", Some("Detached and locked"));
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_locked_detached_multi(mut repo: TestRepo) {
let _other_worktree = repo.add_worktree("other");
let _locked_worktree = repo.add_worktree("locked-detached");
repo.detach_head_in_worktree("locked-detached");
repo.lock_worktree("locked-detached", Some("Locked detached"));
let locked_path = repo.worktree_path("locked-detached");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["@", "other"],
Some(locked_path)
));
}
#[rstest]
fn test_remove_by_name_from_main(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-a");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["feature-a"], None));
}
#[rstest]
fn test_remove_by_name_from_other_worktree(mut repo: TestRepo) {
let worktree_a = repo.add_worktree("feature-a");
let _worktree_b = repo.add_worktree("feature-b");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-b"],
Some(&worktree_a)
));
}
#[rstest]
fn test_remove_current_by_name(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-current");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-current"],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_nonexistent_worktree(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["nonexistent"], None));
}
#[rstest]
fn test_remove_branch_no_worktree_path_occupied(mut repo: TestRepo) {
repo.git_command().args(["branch", "npm"]).run().unwrap();
let _other_worktree = repo.add_worktree("other");
let npm_expected_path = repo.root_path().parent().unwrap().join(format!(
"{}.npm",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
let other_path = repo.root_path().parent().unwrap().join(format!(
"{}.other",
repo.root_path().file_name().unwrap().to_str().unwrap()
));
repo.git_command()
.args([
"worktree",
"remove",
"--force",
other_path.to_str().unwrap(),
])
.run()
.unwrap();
repo.git_command()
.args([
"worktree",
"add",
npm_expected_path.to_str().unwrap(),
"other",
])
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["npm"], None));
}
#[rstest]
fn test_remove_multiple_nonexistent_force(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["-D", "foo", "bar", "baz"],
None
));
}
#[rstest]
fn test_remove_remote_only_branch(#[from(repo_with_remote)] repo: TestRepo) {
repo.run_git(&["branch", "remote-feature"]);
repo.run_git(&["push", "origin", "remote-feature"]);
repo.run_git(&["branch", "-D", "remote-feature"]);
repo.run_git(&["fetch", "origin"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["remote-feature"],
None
));
}
#[rstest]
fn test_remove_nonexistent_branch(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["nonexistent"], None));
}
#[rstest]
fn test_remove_partial_success(mut repo: TestRepo) {
let _feature_path = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature", "nonexistent"],
None
));
let worktrees_dir = repo.root_path().parent().unwrap();
assert!(
!worktrees_dir.join("feature").exists(),
"feature worktree should have been removed despite partial failure"
);
}
#[rstest]
fn test_remove_by_name_dirty_target(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-dirty");
std::fs::write(worktree_path.join("dirty.txt"), "uncommitted changes").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["feature-dirty"], None));
}
#[rstest]
fn test_remove_force_with_untracked_files(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-untracked");
std::fs::write(worktree_path.join("devbox.lock"), "untracked content").unwrap();
let status = repo
.git_command()
.args(["status", "--porcelain"])
.current_dir(&worktree_path)
.run()
.unwrap();
let status_output = String::from_utf8_lossy(&status.stdout);
assert!(
status_output.contains("?? devbox.lock"),
"File should be untracked"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--force", "feature-untracked"],
None
));
}
#[rstest]
fn test_remove_force_with_modified_files(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-modified");
std::fs::write(worktree_path.join("tracked.txt"), "original content").unwrap();
repo.git_command()
.args(["add", "tracked.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add tracked file"])
.current_dir(&worktree_path)
.run()
.unwrap();
std::fs::write(worktree_path.join("tracked.txt"), "modified content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--force", "feature-modified"],
None
));
}
#[rstest]
fn test_remove_force_with_staged_files(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-staged");
std::fs::write(worktree_path.join("staged.txt"), "staged content").unwrap();
repo.git_command()
.args(["add", "staged.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--force", "feature-staged"],
None
));
}
#[rstest]
fn test_remove_force_with_force_delete(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-dirty-unmerged");
repo.git_command()
.args(["commit", "--allow-empty", "-m", "feature commit"])
.current_dir(&worktree_path)
.run()
.unwrap();
std::fs::write(worktree_path.join("untracked.txt"), "dirty").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--force", "-D", "feature-dirty-unmerged"],
None
));
}
#[rstest]
fn test_remove_force_actually_deletes_directory_with_untracked(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-untracked-delete");
repo.git_command()
.args(["commit", "--allow-empty", "-m", "feature commit"])
.current_dir(&worktree_path)
.run()
.unwrap();
std::fs::write(worktree_path.join("untracked.txt"), "untracked content").unwrap();
std::fs::create_dir_all(worktree_path.join("untracked_dir")).unwrap();
std::fs::write(
worktree_path.join("untracked_dir/nested.txt"),
"nested untracked",
)
.unwrap();
assert!(
worktree_path.exists(),
"Worktree should exist before removal"
);
let output = repo
.wt_command()
.args([
"remove",
"--force",
"-D",
"--foreground",
"feature-untracked-delete",
])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove --force -D should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!worktree_path.exists(),
"Worktree directory should be deleted after `wt remove --force -D`, but it still exists"
);
let branch_list = repo
.git_command()
.args(["branch", "--list", "feature-untracked-delete"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&branch_list.stdout)
.trim()
.is_empty(),
"Branch should be deleted with -D flag"
);
}
#[rstest]
fn test_remove_multiple_worktrees(mut repo: TestRepo) {
let _worktree_a = repo.add_worktree("feature-a");
let _worktree_b = repo.add_worktree("feature-b");
let _worktree_c = repo.add_worktree("feature-c");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-a", "feature-b", "feature-c"],
None
));
}
#[rstest]
fn test_remove_multiple_including_current(mut repo: TestRepo) {
let worktree_a = repo.add_worktree("feature-a");
let _worktree_b = repo.add_worktree("feature-b");
let _worktree_c = repo.add_worktree("feature-c");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-a", "feature-b", "feature-c"],
Some(&worktree_a)
));
}
#[rstest]
fn test_remove_branch_not_fully_merged(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-unmerged");
std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.current_dir(&worktree_path)
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-unmerged"],
None
));
}
#[rstest]
fn test_remove_foreground(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-fg");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-fg"],
None
));
}
#[rstest]
fn test_remove_conflicting_branch_flags(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["-D", "--no-delete-branch", "nonexistent"],
None
));
}
#[rstest]
fn test_remove_foreground_unmerged(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-unmerged-fg");
std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.current_dir(&worktree_path)
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-unmerged-fg"],
None
));
}
#[rstest]
fn test_remove_foreground_no_delete_branch(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-fg-keep");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "--no-delete-branch", "feature-fg-keep"],
None
));
}
#[rstest]
fn test_remove_foreground_no_delete_branch_unmerged(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-fg-unmerged-keep");
std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[
"--foreground",
"--no-delete-branch",
"feature-fg-unmerged-keep",
],
None
));
}
#[rstest]
fn test_remove_no_delete_branch(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-keep");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--no-delete-branch", "feature-keep"],
None
));
}
#[rstest]
fn test_remove_no_delete_branch_unmerged(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-unmerged-keep");
std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--no-delete-branch", "feature-unmerged-keep"],
None
));
}
#[rstest]
fn test_remove_branch_only_merged(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature-merged"])
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-merged"],
None
));
}
#[rstest]
fn test_remove_branch_only_unmerged(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature-unmerged"])
.run()
.unwrap();
repo.git_command()
.args(["checkout", "feature-unmerged"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-unmerged"],
None
));
}
#[rstest]
fn test_remove_branch_only_force_delete(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature-force"])
.run()
.unwrap();
repo.git_command()
.args(["checkout", "feature-force"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("feature.txt"), "new feature").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature"])
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--force-delete", "feature-force"],
None
));
}
#[rstest]
fn test_remove_from_detached_head_in_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[],
Some(&worktree_path)
));
}
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_remove_foreground_detached_head(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached-fg");
repo.detach_head_in_worktree("feature-detached-fg");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground"],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_at_from_detached_head_in_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached-at");
repo.detach_head_in_worktree("feature-detached-at");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["@"],
Some(&worktree_path)
));
}
#[rstest]
fn test_remove_branch_matching_tree_content(repo: TestRepo) {
repo.git_command()
.args(["branch", "feature-squashed"])
.run()
.unwrap();
repo.git_command()
.args(["checkout", "feature-squashed"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("feature.txt"), "squash content").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature (on feature branch)"])
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
std::fs::write(repo.root_path().join("feature.txt"), "squash content").unwrap();
repo.git_command()
.args(["add", "feature.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature (squash merged)"])
.run()
.unwrap();
let is_ancestor = repo
.git_command()
.args(["merge-base", "--is-ancestor", "feature-squashed", "main"])
.run()
.unwrap();
assert!(
!is_ancestor.status.success(),
"feature-squashed should NOT be an ancestor of main"
);
let feature_tree = String::from_utf8(
repo.git_command()
.args(["rev-parse", "feature-squashed^{tree}"])
.run()
.unwrap()
.stdout,
)
.unwrap();
let main_tree = String::from_utf8(
repo.git_command()
.args(["rev-parse", "main^{tree}"])
.run()
.unwrap()
.stdout,
)
.unwrap();
assert_eq!(
feature_tree.trim(),
main_tree.trim(),
"Tree SHAs should match (same content)"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-squashed"],
None
));
}
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_remove_main_worktree_vs_linked_worktree(mut repo: TestRepo) {
let linked_wt_path = repo.add_worktree("feature");
assert_cmd_snapshot!(
"remove_main_vs_linked__from_linked_succeeds",
make_snapshot_cmd(&repo, "remove", &["--foreground"], Some(&linked_wt_path))
);
let _linked_wt_path = repo.add_worktree("feature2");
assert_cmd_snapshot!(
"remove_main_vs_linked__from_main_by_name_succeeds",
make_snapshot_cmd(&repo, "remove", &["feature2"], None)
);
assert_cmd_snapshot!(
"remove_main_vs_linked__main_on_default_fails",
make_snapshot_cmd(&repo, "remove", &[], None)
);
repo.run_git(&["switch", "-c", "feature-in-main"]);
assert_cmd_snapshot!(
"remove_main_vs_linked__main_on_feature_fails",
make_snapshot_cmd(&repo, "remove", &[], None)
);
repo.run_git(&["switch", "main"]);
let linked_for_test = repo.add_worktree("test-from-linked");
assert_cmd_snapshot!(
"remove_main_vs_linked__main_by_name_from_linked_fails",
make_snapshot_cmd(&repo, "remove", &["main"], Some(&linked_for_test))
);
}
#[test]
fn test_remove_default_branch_refused() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit on main");
let feature_worktree = test.create_worktree("feature", "feature");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = test.wt_command();
cmd.args(["remove", "--foreground", "main"])
.current_dir(&feature_worktree);
assert_cmd_snapshot!("remove_default_branch_refused", cmd);
});
settings.bind(|| {
let mut cmd = test.wt_command();
cmd.args(["remove", "--foreground", "-D", "main"])
.current_dir(&feature_worktree);
assert_cmd_snapshot!("remove_default_branch_force_delete", cmd);
});
}
#[test]
fn test_remove_default_branch_branch_only() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit on main");
let feature_worktree = test.create_worktree("feature", "feature");
std::fs::remove_dir_all(&main_worktree).unwrap();
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = test.wt_command();
cmd.args(["remove", "main"]).current_dir(&feature_worktree);
assert_cmd_snapshot!("remove_default_branch_branch_only_refused", cmd);
});
settings.bind(|| {
let mut cmd = test.wt_command();
cmd.args(["remove", "-D", "main"])
.current_dir(&feature_worktree);
assert_cmd_snapshot!("remove_default_branch_branch_only_force_delete", cmd);
});
}
#[rstest]
fn test_remove_squash_merged_then_main_advanced(repo: TestRepo) {
repo.git_command()
.args(["checkout", "-b", "feature-squash"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("feature-a.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "feature-a.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature A"])
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
std::fs::write(repo.root_path().join("feature-a.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "feature-a.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature A (squash merged)"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("main-b.txt"), "main content").unwrap();
repo.git_command()
.args(["add", "main-b.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Main advances with B"])
.run()
.unwrap();
let is_ancestor = repo
.git_command()
.args(["merge-base", "--is-ancestor", "feature-squash", "main"])
.run()
.unwrap();
assert!(
!is_ancestor.status.success(),
"feature-squash should NOT be an ancestor of main (squash merge)"
);
let feature_tree = String::from_utf8(
repo.git_command()
.args(["rev-parse", "feature-squash^{tree}"])
.run()
.unwrap()
.stdout,
)
.unwrap();
let main_tree = String::from_utf8(
repo.git_command()
.args(["rev-parse", "main^{tree}"])
.run()
.unwrap()
.stdout,
)
.unwrap();
assert_ne!(
feature_tree.trim(),
main_tree.trim(),
"Tree SHAs should differ (main has file B that feature doesn't)"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-squash"],
None
));
}
#[rstest]
fn test_remove_squash_merged_then_same_files_modified(repo: TestRepo) {
repo.git_command()
.args(["checkout", "-b", "feature-squash-conflict"])
.run()
.unwrap();
std::fs::write(repo.root_path().join("feature-a.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "feature-a.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature A"])
.run()
.unwrap();
repo.git_command().args(["checkout", "main"]).run().unwrap();
std::fs::write(repo.root_path().join("feature-a.txt"), "feature content").unwrap();
repo.git_command()
.args(["add", "feature-a.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Add feature A (squash merged)"])
.run()
.unwrap();
std::fs::write(
repo.root_path().join("feature-a.txt"),
"feature content\nplus more changes on main",
)
.unwrap();
repo.git_command()
.args(["add", "feature-a.txt"])
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "Main advances same file"])
.run()
.unwrap();
let merge_tree_result = repo
.git_command()
.args([
"merge-tree",
"--write-tree",
"main",
"feature-squash-conflict",
])
.run()
.unwrap();
assert!(
!merge_tree_result.status.success(),
"merge-tree should report conflicts (both sides modified feature-a.txt)"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-squash-conflict"],
None
));
}
#[rstest]
fn test_remove_squash_merged_on_remote(#[from(repo_with_remote)] repo: TestRepo) {
let remote_path = repo.remote_path().unwrap();
repo.run_git(&["checkout", "-b", "feature-remote-squash"]);
std::fs::write(repo.root_path().join("feature.txt"), "initial").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Add feature file"]);
std::fs::write(repo.root_path().join("feature.txt"), "revised").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Revise feature"]);
std::fs::write(repo.root_path().join("feature.txt"), "final version").unwrap();
repo.run_git(&["add", "feature.txt"]);
repo.run_git(&["commit", "-m", "Finalize feature"]);
repo.run_git(&["push", "-u", "origin", "feature-remote-squash"]);
repo.run_git(&["checkout", "main"]);
let github_sim = repo.home_path().join("github-sim");
repo.run_git_in(
repo.home_path(),
&["clone", remote_path.to_str().unwrap(), "github-sim"],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-remote-squash"],
);
repo.run_git_in(&github_sim, &["commit", "-m", "Add feature (#1)"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
repo.run_git(&["fetch", "origin"]);
let local_main = repo.git_output(&["rev-parse", "main"]);
let origin_main = repo.git_output(&["rev-parse", "origin/main"]);
assert_ne!(
local_main, origin_main,
"local main should be behind origin/main"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-remote-squash"],
None
));
}
#[rstest]
fn test_remove_squash_merged_on_remote_when_local_main_diverged(
#[from(repo_with_remote)] repo: TestRepo,
) {
let remote_path = repo.remote_path().unwrap();
repo.run_git(&["checkout", "-b", "feature-remote-squash-diverged"]);
std::fs::write(repo.root_path().join("feature-diverged.txt"), "initial").unwrap();
repo.run_git(&["add", "feature-diverged.txt"]);
repo.run_git(&["commit", "-m", "Add diverged feature"]);
std::fs::write(
repo.root_path().join("feature-diverged.txt"),
"final version",
)
.unwrap();
repo.run_git(&["add", "feature-diverged.txt"]);
repo.run_git(&["commit", "-m", "Finalize diverged feature"]);
repo.run_git(&["push", "-u", "origin", "feature-remote-squash-diverged"]);
repo.run_git(&["checkout", "main"]);
let github_sim = repo.home_path().join("github-sim-diverged");
repo.run_git_in(
repo.home_path(),
&[
"clone",
remote_path.to_str().unwrap(),
"github-sim-diverged",
],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-remote-squash-diverged"],
);
repo.run_git_in(&github_sim, &["commit", "-m", "Add diverged feature (#3)"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
repo.run_git(&["fetch", "origin"]);
std::fs::write(repo.root_path().join("local-only.txt"), "local only").unwrap();
repo.run_git(&["add", "local-only.txt"]);
repo.run_git(&["commit", "-m", "Local-only main commit"]);
let local_main = repo.git_output(&["rev-parse", "main"]);
let origin_main = repo.git_output(&["rev-parse", "origin/main"]);
assert_ne!(
local_main, origin_main,
"local main should diverge from origin/main"
);
let local_behind_remote = repo
.git_command()
.args(["merge-base", "--is-ancestor", "main", "origin/main"])
.run()
.unwrap();
assert!(
!local_behind_remote.status.success(),
"local main should not be an ancestor of origin/main in diverged state"
);
let remote_behind_local = repo
.git_command()
.args(["merge-base", "--is-ancestor", "origin/main", "main"])
.run()
.unwrap();
assert!(
!remote_behind_local.status.success(),
"origin/main should not be an ancestor of local main in diverged state"
);
let output = make_snapshot_cmd(&repo, "remove", &["feature-remote-squash-diverged"], None)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("Removed branch feature-remote-squash-diverged"),
"expected branch to be removed once origin/main contains the squash merge\nstderr:\n{stderr}",
);
assert!(
stderr.contains("origin/main"),
"expected remove output to mention origin/main as the integration target\nstderr:\n{stderr}",
);
let branch_still_exists = repo
.git_command()
.args([
"rev-parse",
"--verify",
"--quiet",
"refs/heads/feature-remote-squash-diverged",
])
.run()
.unwrap();
assert!(
!branch_still_exists.status.success(),
"feature branch should be deleted after successful remove"
);
}
#[rstest]
fn test_remove_squash_merged_on_remote_then_advanced(#[from(repo_with_remote)] repo: TestRepo) {
let remote_path = repo.remote_path().unwrap();
repo.run_git(&["checkout", "-b", "feature-remote-squash2"]);
std::fs::write(repo.root_path().join("feature2.txt"), "draft").unwrap();
repo.run_git(&["add", "feature2.txt"]);
repo.run_git(&["commit", "-m", "WIP: start feature 2"]);
std::fs::write(repo.root_path().join("feature2.txt"), "done").unwrap();
repo.run_git(&["add", "feature2.txt"]);
repo.run_git(&["commit", "-m", "Complete feature 2"]);
repo.run_git(&["push", "-u", "origin", "feature-remote-squash2"]);
repo.run_git(&["checkout", "main"]);
let github_sim = repo.home_path().join("github-sim2");
repo.run_git_in(
repo.home_path(),
&["clone", remote_path.to_str().unwrap(), "github-sim2"],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-remote-squash2"],
);
repo.run_git_in(&github_sim, &["commit", "-m", "Add feature 2 (#2)"]);
std::fs::write(github_sim.join("other.txt"), "other content").unwrap();
repo.run_git_in(&github_sim, &["add", "other.txt"]);
repo.run_git_in(&github_sim, &["commit", "-m", "Unrelated commit"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
repo.run_git(&["fetch", "origin"]);
let local_main = repo.git_output(&["rev-parse", "main"]);
let origin_main = repo.git_output(&["rev-parse", "origin/main"]);
assert_ne!(
local_main, origin_main,
"local main should be behind origin/main"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-remote-squash2"],
None
));
}
#[rstest]
fn test_remove_worktree_squash_merged_on_remote(#[from(repo_with_remote)] mut repo: TestRepo) {
let remote_path = repo.remote_path().unwrap().to_path_buf();
let _wt_path = repo.add_worktree("feature-wt-squash");
let wt_path = repo.worktrees["feature-wt-squash"].clone();
std::fs::write(wt_path.join("feature-wt.txt"), "feature content").unwrap();
repo.run_git_in(&wt_path, &["add", "feature-wt.txt"]);
repo.run_git_in(&wt_path, &["commit", "-m", "Add feature"]);
repo.run_git_in(&wt_path, &["push", "-u", "origin", "feature-wt-squash"]);
let github_sim = repo.home_path().join("github-sim-wt");
repo.run_git_in(
repo.home_path(),
&["clone", remote_path.to_str().unwrap(), "github-sim-wt"],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-wt-squash"],
);
repo.run_git_in(&github_sim, &["commit", "-m", "Add feature (#1)"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
repo.run_git(&["fetch", "origin"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-wt-squash"],
None
));
}
#[rstest]
fn test_pre_remove_hook_executes(mut repo: TestRepo) {
repo.write_project_config(r#"pre-remove = "echo 'About to remove worktree'""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'About to remove worktree'"]
"#,
);
let _worktree_path = repo.add_worktree("feature-hook");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-hook"],
None
));
}
#[rstest]
fn test_pre_remove_hook_template_variables(mut repo: TestRepo) {
repo.write_project_config(
r#"[pre-remove]
branch = "echo 'Branch: {{ branch }}'"
worktree = "echo 'Worktree: {{ worktree_path }}'"
worktree_name = "echo 'Name: {{ worktree_name }}'"
"#,
);
repo.commit("Add config with templates");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Branch: {{ branch }}'",
"echo 'Worktree: {{ worktree_path }}'",
"echo 'Name: {{ worktree_name }}'",
]
"#,
);
let _worktree_path = repo.add_worktree("feature-templates");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-templates"],
None
));
}
#[rstest]
fn test_pre_remove_hook_runs_in_background_mode(mut repo: TestRepo) {
use crate::common::wait_for_file;
let marker_file = repo.root_path().join("hook-ran.txt");
repo.write_project_config(&format!(
r#"pre-remove = "echo 'hook ran' > {}""#,
marker_file.to_slash_lossy()
));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["echo 'hook ran' > {}"]
"#,
marker_file.to_slash_lossy()
));
let _worktree_path = repo.add_worktree("feature-bg");
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_wt"));
repo.configure_wt_cmd(&mut cmd);
cmd.current_dir(repo.root_path())
.args(["remove", "feature-bg"])
.output()
.unwrap();
wait_for_file(&marker_file);
assert!(
marker_file.exists(),
"Pre-remove hook should run even in background mode"
);
}
#[rstest]
fn test_pre_remove_hook_failure_aborts(mut repo: TestRepo) {
repo.write_project_config(r#"pre-remove = "exit 1""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["exit 1"]
"#,
);
let worktree_path = repo.add_worktree("feature-fail");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-fail"],
None
));
assert!(
worktree_path.exists(),
"Worktree should NOT be removed when hook fails"
);
}
#[rstest]
fn test_pre_remove_hook_failure_no_cd_directive(mut repo: TestRepo) {
repo.write_project_config(r#"pre-remove = "exit 1""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["exit 1"]
"#,
);
let worktree_path = repo.add_worktree("feature-cd-test");
let (directive_path, _guard) = directive_file();
let mut cmd = repo.wt_command();
cmd.args(["remove", "--foreground"]);
cmd.current_dir(&worktree_path);
configure_directive_file(&mut cmd, &directive_path);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"Remove should fail when pre-remove hook fails"
);
let directives = std::fs::read_to_string(&directive_path).unwrap_or_default();
assert!(
!directives.contains("cd "),
"Directive file should NOT contain cd when hook fails, got: {}",
directives
);
assert!(
worktree_path.exists(),
"Worktree should NOT be removed when hook fails"
);
}
#[rstest]
fn test_pre_remove_hook_not_for_branch_only(repo: TestRepo) {
let marker_file = repo.root_path().join("branch-only-hook.txt");
repo.write_project_config(&format!(
r#"pre-remove = "echo 'hook ran' > {}""#,
marker_file.to_slash_lossy()
));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["echo 'hook ran' > {}"]
"#,
marker_file.to_slash_lossy()
));
repo.git_command()
.args(["branch", "branch-only"])
.run()
.unwrap();
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_wt"));
repo.configure_wt_cmd(&mut cmd);
cmd.current_dir(repo.root_path())
.args(["remove", "branch-only"])
.output()
.unwrap();
assert!(
!marker_file.exists(),
"Pre-remove hook should NOT run for branch-only removal"
);
}
#[rstest]
fn test_pre_remove_hook_skipped_with_no_hooks(mut repo: TestRepo) {
use std::thread;
let marker_file = repo.root_path().join("should-not-exist.txt");
repo.write_project_config(&format!(
r#"pre-remove = "echo 'hook ran' > {}""#,
marker_file.to_slash_lossy()
));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["echo 'hook ran' > {}"]
"#,
marker_file.to_slash_lossy()
));
let worktree_path = repo.add_worktree("feature-skip");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "--no-hooks", "feature-skip"],
None
));
thread::sleep(Duration::from_millis(500));
assert!(
!marker_file.exists(),
"Pre-remove hook should NOT run with --no-hooks"
);
assert!(
!worktree_path.exists(),
"Worktree should be removed even with --no-hooks"
);
}
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_pre_remove_hook_runs_for_detached_head(mut repo: TestRepo) {
let marker_file = repo.root_path().join("m.txt");
let marker_path = marker_file.to_slash_lossy();
repo.write_project_config(&format!(r#"pre-remove = "touch {marker_path}""#,));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["touch {marker_path}"]
"#,
));
let worktree_path = repo.add_worktree("feature-detached-hook");
repo.detach_head_in_worktree("feature-detached-hook");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground"],
Some(&worktree_path)
));
assert!(
marker_file.exists(),
"Pre-remove hook should run for detached HEAD worktrees"
);
}
#[rstest]
fn test_pre_remove_hook_runs_for_detached_head_background(mut repo: TestRepo) {
let marker_file = repo.root_path().join("detached-bg-hook-marker.txt");
let marker_path = marker_file.to_slash_lossy();
repo.write_project_config(&format!(r#"pre-remove = "touch {marker_path}""#,));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["touch {marker_path}"]
"#,
));
let worktree_path = repo.add_worktree("feature-detached-bg");
repo.detach_head_in_worktree("feature-detached-bg");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[],
Some(&worktree_path)
));
assert!(
marker_file.exists(),
"Pre-remove hook should run for detached HEAD worktrees in background mode"
);
}
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_pre_remove_hook_branch_expansion_detached_head(mut repo: TestRepo) {
let branch_file = repo.root_path().join("branch-expansion.txt");
let branch_path = branch_file.to_slash_lossy();
repo.write_project_config(&format!(
r#"pre-remove = "echo 'branch={{{{ branch }}}}' > {branch_path}""#,
));
repo.commit("Add config");
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["echo 'branch={{{{ branch }}}}' > {branch_path}"]
"#,
));
let worktree_path = repo.add_worktree("feature-branch-test");
repo.detach_head_in_worktree("feature-branch-test");
let output = wt_command()
.args(["remove", "--foreground"])
.current_dir(&worktree_path)
.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path())
.env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path())
.output()
.expect("Failed to execute wt remove");
assert!(
output.status.success(),
"wt remove should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let content =
std::fs::read_to_string(&branch_file).expect("Hook should have created the branch file");
assert_eq!(
content.trim(),
"branch=HEAD",
"{{ branch }} should expand to 'HEAD' for detached HEAD worktrees"
);
}
#[rstest]
fn test_remove_path_mismatch_warning(repo: TestRepo) {
let unexpected_path = repo
.root_path()
.parent()
.unwrap()
.join("weird-path-for-feature");
repo.git_command()
.args([
"worktree",
"add",
unexpected_path.to_str().unwrap(),
"-b",
"feature",
])
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["feature"], None));
}
#[rstest]
fn test_remove_path_mismatch_warning_foreground(repo: TestRepo) {
let unexpected_path = repo
.root_path()
.parent()
.unwrap()
.join("another-weird-path");
repo.git_command()
.args([
"worktree",
"add",
unexpected_path.to_str().unwrap(),
"-b",
"feature-fg",
])
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--foreground", "feature-fg"],
None
));
}
#[rstest]
fn test_remove_detached_worktree_in_multi(mut repo: TestRepo) {
let _feature_a = repo.add_worktree("feature-a");
let _feature_b = repo.add_worktree("feature-b");
repo.detach_head_in_worktree("feature-b");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-a", "feature-b"],
None
));
}
#[rstest]
fn test_remove_detached_by_name_fails(mut repo: TestRepo) {
repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["(detached)"], None));
}
#[rstest]
fn test_remove_detached_worktree_by_path(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
assert!(worktree_path.exists());
let worktree_str = worktree_path.to_string_lossy().to_string();
let output = repo
.wt_command()
.args(["remove", &worktree_str, "--foreground", "--yes"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!worktree_path.exists(),
"Worktree directory should be removed"
);
}
#[rstest]
fn test_remove_detached_worktree_by_relative_path(mut repo: TestRepo) {
repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
let relative_path = "../repo.feature-detached";
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&[relative_path, "--foreground", "--yes"],
None,
));
}
#[cfg(unix)]
#[rstest]
fn test_remove_at_symbol_via_symlink(mut repo: TestRepo) {
use std::os::unix::fs::symlink;
let worktree_path = repo.add_worktree("feature-symlink");
let symlink_path = repo
.root_path()
.parent()
.unwrap()
.join("symlink-to-feature");
symlink(&worktree_path, &symlink_path).expect("Failed to create symlink");
assert!(
symlink_path.is_symlink(),
"Symlink should exist at {:?}",
symlink_path
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["@"],
Some(&symlink_path)
));
}
#[rstest]
fn test_remove_pruned_worktree_directory_missing(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-pruned");
assert!(worktree_path.exists(), "Worktree should exist initially");
std::fs::remove_dir_all(&worktree_path).expect("Failed to remove worktree directory");
assert!(
!worktree_path.exists(),
"Worktree directory should be deleted"
);
let list_output = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
let list_str = String::from_utf8_lossy(&list_output.stdout);
assert!(
list_str.contains("feature-pruned"),
"Git should still list the stale worktree"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-pruned"],
None
));
let list_after = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
let list_after_str = String::from_utf8_lossy(&list_after.stdout);
assert!(
!list_after_str.contains("feature-pruned"),
"Stale worktree should be pruned"
);
let branch_exists = repo
.git_command()
.args(["branch", "--list", "feature-pruned"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&branch_exists.stdout)
.trim()
.is_empty(),
"Branch should be deleted"
);
}
#[rstest]
fn test_remove_pruned_worktree_keep_branch(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-pruned-keep");
std::fs::remove_dir_all(&worktree_path).expect("Failed to remove worktree directory");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["--no-delete-branch", "feature-pruned-keep"],
None
));
let branch_exists = repo
.git_command()
.args(["branch", "--list", "feature-pruned-keep"])
.run()
.unwrap();
assert!(
!String::from_utf8_lossy(&branch_exists.stdout)
.trim()
.is_empty(),
"Branch should still exist"
);
}
#[rstest]
fn test_remove_pruned_worktree_unmerged_branch(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-pruned-unmerged");
std::fs::write(worktree_path.join("unmerged.txt"), "unmerged work\n").unwrap();
repo.git_command()
.args(["add", "unmerged.txt"])
.current_dir(&worktree_path)
.run()
.unwrap();
repo.git_command()
.args(["commit", "-m", "unmerged work"])
.current_dir(&worktree_path)
.run()
.unwrap();
std::fs::remove_dir_all(&worktree_path).expect("Failed to remove worktree directory");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"remove",
&["feature-pruned-unmerged"],
None
));
let branch_exists = repo
.git_command()
.args(["branch", "--list", "feature-pruned-unmerged"])
.run()
.unwrap();
assert!(
!String::from_utf8_lossy(&branch_exists.stdout)
.trim()
.is_empty(),
"Unmerged branch should be retained"
);
}
#[rstest]
fn test_remove_background_path_gone_immediately(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-instant");
assert!(worktree_path.exists(), "Worktree should exist initially");
let output = repo
.wt_command()
.args(["remove", "feature-instant"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!worktree_path.exists(), "Worktree should be fully removed");
}
#[rstest]
fn test_remove_background_git_metadata_pruned(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-prune-test");
let list_before = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&list_before.stdout).contains("feature-prune-test"),
"Git should list the worktree before removal"
);
let output = repo
.wt_command()
.args(["remove", "feature-prune-test"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let list_after = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
assert!(
!String::from_utf8_lossy(&list_after.stdout).contains("feature-prune-test"),
"Git should NOT list the worktree after removal (metadata should be pruned)"
);
}
#[rstest]
fn test_remove_background_deletes_merged_branch(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature-merged");
let branches_before = repo
.git_command()
.args(["branch", "--list", "feature-merged"])
.run()
.unwrap();
assert!(
!String::from_utf8_lossy(&branches_before.stdout)
.trim()
.is_empty(),
"Branch should exist before removal"
);
let output = repo
.wt_command()
.args(["remove", "feature-merged"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let branches_after = repo
.git_command()
.args(["branch", "--list", "feature-merged"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&branches_after.stdout)
.trim()
.is_empty(),
"Branch should be deleted synchronously after wt remove returns"
);
}
#[rstest]
fn test_remove_worktree_with_special_path_chars(mut repo: TestRepo) {
let _worktree_path = repo.add_worktree("feature--double-dash");
let list_before = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&list_before.stdout).contains("feature--double-dash"),
"Worktree should exist before removal"
);
let output = repo
.wt_command()
.args(["remove", "feature--double-dash"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed for path with special chars: {}",
String::from_utf8_lossy(&output.stderr)
);
crate::common::wait_for("worktree with special chars removed", || {
let list = repo
.git_command()
.args(["worktree", "list", "--porcelain"])
.run()
.unwrap();
!String::from_utf8_lossy(&list.stdout).contains("feature--double-dash")
});
}
#[rstest]
fn test_remove_background_fallback_on_rename_failure(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-fallback");
let git_common_dir = crate::common::resolve_git_common_dir(repo.root_path());
let trash_dir = git_common_dir.join("wt/trash");
std::fs::create_dir_all(&trash_dir).unwrap();
let staged_path = trash_dir.join(format!(
"{}-{}",
worktree_path.file_name().unwrap().to_string_lossy(),
crate::common::TEST_EPOCH
));
std::fs::write(&staged_path, "blocking file").unwrap();
assert!(
worktree_path.exists(),
"Worktree should exist before removal"
);
let output = repo
.wt_command()
.args(["remove", "feature-fallback"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt remove should succeed even when instant rename fails: {}",
String::from_utf8_lossy(&output.stderr)
);
crate::common::wait_for("worktree removed by legacy fallback", || {
!worktree_path.exists()
});
crate::common::wait_for("branch deleted by legacy fallback", || {
let branches = repo
.git_command()
.args(["branch", "--list", "feature-fallback"])
.run()
.unwrap();
String::from_utf8_lossy(&branches.stdout).trim().is_empty()
});
let _ = std::fs::remove_file(&staged_path);
}
#[rstest]
fn test_remove_stale_staging_dir_from_crashed_removal(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-crash");
let git_common_dir = crate::common::resolve_git_common_dir(repo.root_path());
let trash_dir = git_common_dir.join("wt/trash");
std::fs::create_dir_all(&trash_dir).unwrap();
let staged_path = trash_dir.join(format!(
"{}-{}",
worktree_path.file_name().unwrap().to_string_lossy(),
crate::common::TEST_EPOCH
));
std::fs::rename(&worktree_path, &staged_path).unwrap();
repo.run_git(&["worktree", "prune"]);
assert!(!worktree_path.exists());
assert!(staged_path.exists());
assert!(
staged_path.starts_with(&git_common_dir),
"Stale staging dir should be inside .git/"
);
}
#[rstest]
#[cfg(unix)]
fn test_remove_foreground_succeeds_with_stuck_directory(mut repo: TestRepo) {
use std::fs::{self, Permissions};
use std::os::unix::fs::PermissionsExt;
let worktree_path = repo.add_worktree("feature-stuck");
fs::write(worktree_path.join(".gitignore"), "stuck/\n").unwrap();
repo.run_git_in(&worktree_path, &["add", ".gitignore"]);
repo.run_git_in(&worktree_path, &["commit", "-m", "Add gitignore"]);
let stuck_dir = worktree_path.join("stuck");
fs::create_dir_all(&stuck_dir).unwrap();
fs::write(stuck_dir.join("file.txt"), "content").unwrap();
fs::set_permissions(&stuck_dir, Permissions::from_mode(0o555)).unwrap();
let test_file = stuck_dir.join("test_write");
if fs::write(&test_file, "test").is_ok() {
let _ = fs::remove_file(&test_file);
fs::set_permissions(&stuck_dir, Permissions::from_mode(0o755)).unwrap();
eprintln!("Skipping - running with elevated privileges");
return;
}
let output = repo
.wt_command()
.args(["remove", "--foreground", "feature-stuck"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let git_dir = repo.root_path().join(".git");
let trash_dir = git_dir.join("wt").join("trash");
if trash_dir.exists() {
for entry in fs::read_dir(&trash_dir).unwrap().flatten() {
restore_dir_permissions(&entry.path());
}
}
assert!(
output.status.success(),
"Remove should succeed via fast path, got: {stderr}"
);
assert!(!worktree_path.exists(), "Worktree directory should be gone");
}
#[rstest]
#[cfg(unix)]
fn test_remove_foreground_succeeds_with_stuck_directory_detached(mut repo: TestRepo) {
use std::fs::{self, Permissions};
use std::os::unix::fs::PermissionsExt;
let worktree_path = repo.add_worktree("feature-stuck-detached");
fs::write(worktree_path.join(".gitignore"), "stuck/\n").unwrap();
repo.run_git_in(&worktree_path, &["add", ".gitignore"]);
repo.run_git_in(&worktree_path, &["commit", "-m", "Add gitignore"]);
repo.detach_head_in_worktree("feature-stuck-detached");
let stuck_dir = worktree_path.join("stuck");
fs::create_dir_all(&stuck_dir).unwrap();
fs::write(stuck_dir.join("file.txt"), "content").unwrap();
fs::set_permissions(&stuck_dir, Permissions::from_mode(0o555)).unwrap();
let test_file = stuck_dir.join("test_write");
if fs::write(&test_file, "test").is_ok() {
let _ = fs::remove_file(&test_file);
fs::set_permissions(&stuck_dir, Permissions::from_mode(0o755)).unwrap();
eprintln!("Skipping - running with elevated privileges");
return;
}
let output = repo
.wt_command()
.args(["remove", "--foreground"])
.current_dir(&worktree_path)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let git_dir = repo.root_path().join(".git");
let trash_dir = git_dir.join("wt").join("trash");
if trash_dir.exists() {
for entry in fs::read_dir(&trash_dir).unwrap().flatten() {
restore_dir_permissions(&entry.path());
}
}
assert!(
output.status.success(),
"Remove should succeed via fast path, got: {stderr}"
);
assert!(!worktree_path.exists(), "Worktree directory should be gone");
}
#[rstest]
fn test_remove_foreground_with_submodules(mut repo: TestRepo) {
let sub_source = repo.root_path().parent().unwrap().join("sub-source");
std::fs::create_dir_all(&sub_source).unwrap();
repo.run_git_in(&sub_source, &["init"]);
std::fs::write(sub_source.join("sub.txt"), "submodule content").unwrap();
repo.run_git_in(&sub_source, &["add", "sub.txt"]);
repo.run_git_in(&sub_source, &["commit", "-m", "sub init"]);
let output = repo
.git_command()
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"add",
sub_source.to_str().unwrap(),
"submod",
])
.run()
.unwrap();
assert!(
output.status.success(),
"Failed to add submodule: {}",
String::from_utf8_lossy(&output.stderr)
);
repo.run_git(&["commit", "-m", "add submodule"]);
let worktree_path = repo.add_worktree("feature-submod");
let output = repo
.git_command()
.current_dir(&worktree_path)
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
])
.run()
.unwrap();
assert!(
output.status.success(),
"Failed to init submodule: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
worktree_path.join("submod").join("sub.txt").exists(),
"Submodule should be initialized"
);
let output = repo
.wt_command()
.args(["remove", "--foreground", "feature-submod"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Remove should succeed with submodules, got: {stderr}"
);
assert!(
!worktree_path.exists(),
"Worktree directory should be removed"
);
}
#[cfg(unix)]
fn restore_dir_permissions(dir: &std::path::Path) {
use std::fs::{self, Permissions};
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, Permissions::from_mode(0o755));
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
restore_dir_permissions(&entry.path());
}
}
}
}
#[rstest]
fn test_remove_json(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("feature");
let output = repo
.wt_command()
.args([
"remove",
"feature",
"--format=json",
"--yes",
"--foreground",
])
.output()
.unwrap();
assert!(output.status.success());
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""path": "[^"]*""#, r#""path": "<PATH>""#);
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[cfg(not(target_os = "windows"))] #[rstest]
fn test_remove_json_current(mut repo: TestRepo) {
repo.commit("initial");
let feature_wt = repo.add_worktree("feature");
let output = repo
.wt_command()
.args(["remove", "--format=json", "--yes", "--foreground"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(output.status.success());
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""path": "[^"]*""#, r#""path": "<PATH>""#);
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[rstest]
fn test_remove_json_branch_only(repo: TestRepo) {
repo.commit("initial");
repo.git_command()
.args(["branch", "orphan-branch"])
.run()
.unwrap();
let output = repo
.wt_command()
.args([
"remove",
"orphan-branch",
"--format=json",
"--yes",
"--foreground",
])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn test_remove_json_multi_with_branch_only(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("wt-feature");
repo.git_command()
.args(["branch", "orphan-branch"])
.run()
.unwrap();
let output = repo
.wt_command()
.args([
"remove",
"wt-feature",
"orphan-branch",
"--format=json",
"--yes",
"--foreground",
])
.output()
.unwrap();
assert!(output.status.success());
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""path": "[^"]*""#, r#""path": "<PATH>""#);
settings.bind(|| {
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
});
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn test_remove_json_multi_with_current(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("other-feature");
let current_wt = repo.add_worktree("current-feature");
let output = repo
.wt_command()
.args([
"remove",
"other-feature",
"current-feature",
"--format=json",
"--yes",
"--foreground",
])
.current_dir(¤t_wt)
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
let items = json.as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["branch"], "other-feature");
assert_eq!(items[0]["kind"], "worktree");
assert_eq!(items[1]["branch"], "current-feature");
assert_eq!(items[1]["kind"], "worktree");
}