use crate::common::{
BareRepoTest, TestRepo, make_snapshot_cmd, repo, setup_temp_snapshot_settings,
};
use insta::assert_snapshot;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
#[rstest]
fn test_prune_no_merged(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree_with_commit("feature", "f.txt", "content", "feature commit");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run", "--min-age=0s"],
None
));
}
#[rstest]
fn test_prune_dry_run(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-a");
repo.add_worktree("merged-b");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run", "--min-age=0s"],
None
));
let parent = repo.root_path().parent().unwrap();
assert!(
parent.join("repo.merged-a").exists(),
"Dry run should not remove worktrees"
);
assert!(
parent.join("repo.merged-b").exists(),
"Dry run should not remove worktrees"
);
}
#[rstest]
fn test_prune_removes_merged(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-branch");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
None
));
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.merged-branch");
assert!(!worktree_path.exists(), "Worktree should be fully removed");
}
#[rstest]
fn test_prune_skips_unmerged(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-one");
repo.add_worktree_with_commit("unmerged", "u.txt", "content", "unmerged commit");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
None
));
let merged_path = repo.root_path().parent().unwrap().join("repo.merged-one");
assert!(
!merged_path.exists(),
"Merged worktree should be fully removed"
);
let unmerged_path = repo.root_path().parent().unwrap().join("repo.unmerged");
assert!(unmerged_path.exists(), "Unmerged worktree should remain");
}
#[rstest]
fn test_prune_min_age_skips_young(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("young-branch");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run"],
None
));
let worktree_path = repo.root_path().parent().unwrap().join("repo.young-branch");
assert!(worktree_path.exists(), "Young worktree should be skipped");
}
#[rstest]
fn test_prune_multiple(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-a");
repo.add_worktree("merged-b");
repo.add_worktree("merged-c");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None);
cmd.env("RAYON_NUM_THREADS", "1"); assert_cmd_snapshot!(cmd);
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.merged-a").exists(),
"merged-a should be fully removed"
);
assert!(
!parent.join("repo.merged-b").exists(),
"merged-b should be fully removed"
);
assert!(
!parent.join("repo.merged-c").exists(),
"merged-c should be fully removed"
);
}
#[rstest]
fn test_prune_skips_unmerged_detached(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-branch");
repo.add_worktree_with_commit("detached-branch", "d.txt", "data", "detached commit");
repo.detach_head_in_worktree("detached-branch");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run", "--min-age=0s"],
None
));
let parent = repo.root_path().parent().unwrap();
assert!(parent.join("repo.merged-branch").exists());
assert!(parent.join("repo.detached-branch").exists());
}
#[rstest]
fn test_prune_removes_integrated_detached(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("detached-integrated");
repo.detach_head_in_worktree("detached-integrated");
let mut cmd = make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s", "--foreground"],
None,
);
cmd.env("RAYON_NUM_THREADS", "1"); assert_cmd_snapshot!(cmd);
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.detached-integrated").exists(),
"Worktree should be fully removed"
);
}
#[rstest]
fn test_prune_removes_multiple_detached(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("detached-a");
repo.detach_head_in_worktree("detached-a");
repo.add_worktree("detached-b");
repo.detach_head_in_worktree("detached-b");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None);
cmd.env("RAYON_NUM_THREADS", "1"); assert_cmd_snapshot!(cmd);
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.detached-a").exists(),
"detached-a should be fully removed"
);
assert!(
!parent.join("repo.detached-b").exists(),
"detached-b should be fully removed"
);
}
#[rstest]
fn test_prune_skips_locked(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-branch");
repo.add_worktree("locked-branch");
repo.lock_worktree("locked-branch", Some("in use"));
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
None
));
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.merged-branch").exists(),
"Merged worktree should be fully removed"
);
assert!(
parent.join("repo.locked-branch").exists(),
"Locked worktree should be skipped"
);
}
#[rstest]
fn test_prune_orphan_branches(mut repo: TestRepo) {
repo.commit("initial");
repo.create_branch("orphan-a");
repo.create_branch("orphan-b");
repo.add_worktree_with_commit("unmerged-orphan", "u.txt", "data", "unique commit");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", "1893456000"); cmd.env("RAYON_NUM_THREADS", "1");
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_prune_orphan_branch_min_age(repo: TestRepo) {
repo.commit("initial");
repo.create_branch("orphan-integrated");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", "1735691400");
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_prune_mixed_worktree_and_orphan_branch(mut repo: TestRepo) {
repo.commit("initial");
repo.create_branch("orphan-mixed");
repo.add_worktree("merged-mixed");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None);
cmd.env("RAYON_NUM_THREADS", "1"); assert_cmd_snapshot!(cmd);
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.merged-mixed").exists(),
"Worktree should be fully removed"
);
}
#[rstest]
#[cfg(not(target_os = "windows"))]
fn test_prune_current_worktree(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("current-merged");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
Some(&wt_path)
));
crate::common::assert_worktree_removed(&wt_path);
}
#[rstest]
fn test_prune_stale_worktree(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("stale-branch");
std::fs::remove_dir_all(&wt_path).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
None
));
}
#[rstest]
fn test_prune_min_age_passes(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("old-merged");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--dry-run"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", "1893456000");
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_prune_skips_dirty(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("dirty-merged");
std::fs::write(wt_path.join("scratch.txt"), "wip").unwrap();
repo.add_worktree("clean-merged");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
None
));
assert!(wt_path.exists(), "Dirty worktree should be skipped");
let clean_path = repo.root_path().parent().unwrap().join("repo.clean-merged");
assert!(
!clean_path.exists(),
"Clean worktree should be fully removed"
);
}
#[rstest]
fn test_prune_dry_run_mixed_worktrees_and_branches(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-a");
repo.add_worktree("merged-b");
repo.create_branch("orphan-integrated");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--dry-run"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", "1893456000");
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_prune_during_rebase(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-wt");
let feature_path = repo.add_worktree_with_commit("rebasing", "r.txt", "v1", "commit 1");
repo.commit_in_worktree(&feature_path, "r.txt", "v2", "commit 2");
let git_status = repo
.git_command()
.args(["rebase", "-i", "--exec", "false", "main"])
.current_dir(&feature_path)
.env("GIT_SEQUENCE_EDITOR", "true")
.run()
.unwrap();
assert!(!git_status.status.success(), "rebase should be paused");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--yes", "--min-age=0s"],
Some(&feature_path)
));
}
#[rstest]
fn test_prune_stale_plus_young(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("stale-branch");
std::fs::remove_dir_all(&wt_path).unwrap();
repo.add_worktree("young-branch");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run"],
None
));
}
#[rstest]
fn test_prune_stale_plus_young_non_dry_run(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("stale-branch");
std::fs::remove_dir_all(&wt_path).unwrap();
repo.add_worktree("young-branch");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["prune", "--yes"], None));
}
#[rstest]
fn test_prune_squash_merged_same_files_modified(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("feature-squash");
std::fs::write(wt_path.join("shared.txt"), "feature content").unwrap();
repo.run_git_in(&wt_path, &["add", "shared.txt"]);
repo.run_git_in(&wt_path, &["commit", "-m", "Add feature"]);
std::fs::write(repo.root_path().join("shared.txt"), "feature content").unwrap();
repo.run_git(&["add", "shared.txt"]);
repo.run_git(&["commit", "-m", "Squash merge feature"]);
std::fs::write(
repo.root_path().join("shared.txt"),
"feature content\nmore main changes",
)
.unwrap();
repo.run_git(&["add", "shared.txt"]);
repo.run_git(&["commit", "-m", "Advance same file on main"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["prune", "--dry-run", "--min-age=0s"],
None
));
}
#[test]
fn test_prune_skips_default_branch_orphan() {
use crate::common::TestRepoBase;
let test = BareRepoTest::new();
let main_wt = test.create_worktree("main", "main");
test.commit_in(&main_wt, "initial commit");
std::fs::remove_dir_all(&main_wt).unwrap();
test.git_command(test.bare_repo_path())
.args(["worktree", "prune"])
.run()
.unwrap();
let feature_wt = test.create_worktree("feature", "feature");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = test.wt_command();
cmd.args(["step", "prune", "--yes"])
.current_dir(&feature_wt)
.env("WORKTRUNK_TEST_EPOCH", "1893456000");
assert_cmd_snapshot!("prune_skips_default_branch_orphan", cmd);
});
let output = test
.git_command(test.bare_repo_path())
.args(["branch", "--list", "main"])
.run()
.unwrap();
let branches = String::from_utf8_lossy(&output.stdout);
assert!(
branches.contains("main"),
"Default branch 'main' should not have been pruned"
);
}
#[rstest]
fn test_prune_dry_run_json(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-a");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--dry-run",
"--min-age=0s",
"--format=json",
])
.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_prune_dry_run_json_empty(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree_with_commit("feature", "f.txt", "content", "feature commit");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--dry-run",
"--min-age=0s",
"--format=json",
])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @"[]");
}
#[rstest]
fn test_prune_json_actual_removal(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-a");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--min-age=0s",
"--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_prune_dry_run_json_current_worktree(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("current-merged");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--dry-run",
"--min-age=0s",
"--format=json",
])
.current_dir(&wt_path)
.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_prune_dry_run_json_orphan_branch(repo: TestRepo) {
repo.commit("initial");
repo.create_branch("orphan-integrated");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--dry-run",
"--min-age=0s",
"--format=json",
])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn test_prune_json_current_worktree(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("current-merged");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--min-age=0s",
"--format=json",
"--yes",
"--foreground",
])
.current_dir(&wt_path)
.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_prune_json_orphan_branch(repo: TestRepo) {
repo.commit("initial");
repo.create_branch("orphan-integrated");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--min-age=0s",
"--format=json",
"--yes",
"--foreground",
])
.output()
.unwrap();
assert!(output.status.success());
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}