use crate::common::{
BareRepoTest, TestRepo, make_snapshot_cmd, repo, repo_with_remote, setup_temp_snapshot_settings,
};
use ansi_str::AnsiStr;
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_detached_worktree_rename_fallback(mut repo: TestRepo) {
repo.commit("initial");
let wt_path = repo.add_worktree("detached-fallback");
repo.detach_head_in_worktree("detached-fallback");
let trash_dir = crate::common::resolve_git_common_dir(repo.root_path()).join("wt/trash");
std::fs::create_dir_all(&trash_dir).unwrap();
let staged_path = trash_dir.join(format!(
"{}-{}",
wt_path.file_name().unwrap().to_string_lossy(),
crate::common::TEST_EPOCH
));
std::fs::write(&staged_path, "blocking file to force fallback").unwrap();
let output = repo
.wt_command()
.args(["step", "prune", "--yes", "--min-age=0s"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"prune should remove a detached worktree via the fallback; stderr:\n{stderr}"
);
assert!(
!wt_path.exists(),
"the detached worktree should be removed before prune exits"
);
let _ = std::fs::remove_file(&staged_path);
}
#[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_skips_unborn_worktree(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-branch");
let orphan_path = repo.root_path().parent().unwrap().join("repo.orphan");
let out = repo
.git_command()
.args([
"worktree",
"add",
"--orphan",
"-b",
"orphan",
orphan_path.to_str().unwrap(),
])
.run()
.unwrap();
assert!(
out.status.success(),
"git worktree add --orphan failed: {}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let output = repo
.wt_command()
.args(["step", "prune", "--yes", "--min-age=0s"])
.current_dir(repo.root_path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Needed a single revision"),
"wt step prune should not bail on unborn worktrees, got stderr:\n{stderr}"
);
assert!(
output.status.success(),
"wt step prune should succeed; stderr:\n{stderr}"
);
let parent = repo.root_path().parent().unwrap();
assert!(
!parent.join("repo.merged-branch").exists(),
"merged worktree should still be pruned"
);
assert!(
orphan_path.exists(),
"unborn worktree is skipped (not a prune candidate), so it should remain"
);
}
#[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
));
}
fn porcelain_worktree_path<'a>(porcelain: &'a str, dir_name: &str) -> &'a str {
porcelain
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.find(|path| {
std::path::Path::new(path)
.file_name()
.is_some_and(|name| name == dir_name)
})
.unwrap_or_else(|| panic!("no worktree ending in {dir_name} in:\n{porcelain}"))
}
#[rstest]
fn test_prune_stale_detached_worktree(repo: TestRepo) {
repo.commit("initial");
let wt_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.stale-detached");
repo.run_git(&[
"worktree",
"add",
"--detach",
wt_path.to_str().unwrap(),
"HEAD",
]);
let branches_before = repo.git_output(&["branch", "--format=%(refname:short)"]);
std::fs::remove_dir_all(&wt_path).unwrap();
let list_before = repo.git_output(&["worktree", "list", "--porcelain"]);
assert!(
list_before.contains("prunable"),
"Git should report stale detached worktree metadata before prune"
);
let wt_path_str = porcelain_worktree_path(&list_before, "repo.stale-detached");
let output = repo
.wt_command()
.args([
"step",
"prune",
"--yes",
"--min-age=0s",
"--format=json",
"--foreground",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(output.status.success(), "prune failed\nstderr:\n{stderr}");
let items: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
items.len(),
1,
"expected one pruned item\nstderr:\n{stderr}"
);
assert!(items[0]["branch"].is_null());
assert_eq!(items[0]["kind"].as_str(), Some("stale_worktree"));
assert_eq!(items[0]["path"].as_str(), Some(wt_path_str));
let list_after = repo.git_output(&["worktree", "list", "--porcelain"]);
assert!(
!list_after.contains(wt_path_str),
"Stale detached worktree metadata should be pruned"
);
let branches_after = repo.git_output(&["branch", "--format=%(refname:short)"]);
assert_eq!(
branches_after, branches_before,
"Pruning stale detached metadata should not delete branches"
);
}
#[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");
repo.create_branch("young-orphan");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--dry-run"], None);
cmd.env("WORKTRUNK_TEST_EPOCH", "1735691400");
assert_cmd_snapshot!(cmd);
}
#[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");
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes"], None);
cmd.env("RAYON_NUM_THREADS", "1"); assert_cmd_snapshot!(cmd);
}
#[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));
}
#[rstest]
fn test_prune_locally_merged_when_upstream_diverged(#[from(repo_with_remote)] mut repo: TestRepo) {
let remote_path = repo.remote_path().unwrap().to_path_buf();
let github_sim = repo.home_path().join("github-sim-prune-local-merge");
repo.run_git_in(
repo.home_path(),
&[
"clone",
remote_path.to_str().unwrap(),
"github-sim-prune-local-merge",
],
);
std::fs::write(github_sim.join("remote-only.txt"), "remote only").unwrap();
repo.run_git_in(&github_sim, &["add", "remote-only.txt"]);
repo.run_git_in(&github_sim, &["commit", "-m", "Remote-only main commit"]);
repo.run_git_in(&github_sim, &["push", "origin", "main"]);
repo.add_worktree("feature-prune-local");
let feature_path = repo.worktree_path("feature-prune-local");
std::fs::write(feature_path.join("feature.txt"), "feature").unwrap();
repo.run_git_in(feature_path, &["add", "feature.txt"]);
repo.run_git_in(feature_path, &["commit", "-m", "Add feature"]);
repo.run_git(&[
"merge",
"--no-ff",
"-m",
"Merge feature",
"feature-prune-local",
]);
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,
"main and origin/main should differ"
);
assert!(
!repo
.git_command()
.args(["merge-base", "--is-ancestor", "main", "origin/main"])
.run()
.unwrap()
.status
.success(),
"local main must not be an ancestor of origin/main",
);
assert!(
!repo
.git_command()
.args(["merge-base", "--is-ancestor", "origin/main", "main"])
.run()
.unwrap()
.status
.success(),
"origin/main must not be an ancestor of local main",
);
let output = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
output.status.success(),
"prune should succeed\nstderr:\n{stderr}",
);
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.feature-prune-local");
assert!(
!worktree_path.exists(),
"locally-merged worktree should be pruned even when origin/main has diverged\nstderr:\n{stderr}",
);
let branch_still_exists = repo
.git_command()
.args([
"rev-parse",
"--verify",
"--quiet",
"refs/heads/feature-prune-local",
])
.run()
.unwrap()
.status
.success();
assert!(
!branch_still_exists,
"locally-merged branch should be deleted alongside its worktree\nstderr:\n{stderr}",
);
}
#[rstest]
fn test_prune_squash_merged_on_remote_when_local_diverged(
#[from(repo_with_remote)] mut repo: TestRepo,
) {
let remote_path = repo.remote_path().unwrap().to_path_buf();
repo.add_worktree("feature-prune-remote-squash");
let feature_path = repo.worktree_path("feature-prune-remote-squash");
std::fs::write(feature_path.join("feature-remote.txt"), "initial").unwrap();
repo.run_git_in(feature_path, &["add", "feature-remote.txt"]);
repo.run_git_in(feature_path, &["commit", "-m", "Add feature"]);
std::fs::write(feature_path.join("feature-remote.txt"), "final").unwrap();
repo.run_git_in(feature_path, &["add", "feature-remote.txt"]);
repo.run_git_in(feature_path, &["commit", "-m", "Finalize feature"]);
repo.run_git_in(
feature_path,
&["push", "-u", "origin", "feature-prune-remote-squash"],
);
let github_sim = repo.home_path().join("github-sim-prune-remote-squash");
repo.run_git_in(
repo.home_path(),
&[
"clone",
remote_path.to_str().unwrap(),
"github-sim-prune-remote-squash",
],
);
repo.run_git_in(
&github_sim,
&["merge", "--squash", "origin/feature-prune-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"]);
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 output = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
output.status.success(),
"prune should succeed\nstderr:\n{stderr}",
);
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.feature-prune-remote-squash");
assert!(
!worktree_path.exists(),
"remotely-squash-merged worktree should be pruned when local main has diverged\nstderr:\n{stderr}",
);
}
#[rstest]
fn test_prune_hook_announcements_include_branch(mut repo: TestRepo) {
repo.commit("initial");
repo.add_worktree("merged-x");
repo.add_worktree("merged-y");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo done"
"#,
);
let mut cmd = make_snapshot_cmd(&repo, "step", &["prune", "--yes", "--min-age=0s"], None);
cmd.env("RAYON_NUM_THREADS", "1");
assert_cmd_snapshot!(cmd);
}
fn prune_pre_remove_setup(repo: &mut TestRepo) -> (std::path::PathBuf, std::path::PathBuf) {
use path_slash::PathExt as _;
let wt_path = repo.add_worktree("merged");
repo.commit("Advance default branch");
let marker = repo.root_path().join("prune-pre-remove-ran.txt");
repo.write_project_config(&format!(
r#"pre-remove = "echo ran > {}""#,
marker.to_slash_lossy()
));
(wt_path, marker)
}
#[rstest]
fn test_prune_pre_remove_needs_approval(mut repo: TestRepo) {
let (wt_path, marker) = prune_pre_remove_setup(&mut repo);
let output = repo
.wt_command()
.args(["step", "prune", "--foreground", "--min-age=0s"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"prune should skip the unapproved candidate, not abort; stderr:\n{stderr}"
);
assert!(
stderr.contains("(approval required)"),
"prune should report the candidate as skipped for approval; stderr:\n{stderr}"
);
assert!(
stderr.contains("wt config approvals add"),
"prune should hint at how to pre-approve; stderr:\n{stderr}"
);
assert!(
stderr.contains("pre-remove: echo ran >"),
"hint should list the unapproved template grouped by hook; stderr:\n{stderr}"
);
let wt_basename = wt_path.file_name().unwrap().to_string_lossy();
assert!(
stderr.contains("wt -C ~/") && stderr.contains(&format!("{wt_basename} remove")),
"hint should offer a per-worktree `wt -C ~/…/{wt_basename} remove` alternative; stderr:\n{stderr}"
);
assert!(
stderr.contains("(different hooks on branch)"),
"candidate without its own .config/wt.toml should be flagged as differing; stderr:\n{stderr}"
);
assert!(
wt_path.exists(),
"the worktree must not be removed when its hooks aren't approved"
);
assert!(
!marker.exists(),
"the pre-remove hook must not run without approval"
);
}
#[rstest]
fn test_prune_unmerged_pre_remove_is_not_approved(mut repo: TestRepo) {
repo.write_project_config(r#"pre-remove = "echo unmerged pre-remove""#);
repo.commit("Add pre-remove hook");
let wt_path = repo.add_worktree_with_commit(
"unmerged-with-hook",
"unmerged.txt",
"content",
"unmerged commit",
);
let output = repo
.wt_command()
.args(["step", "prune", "--foreground", "--min-age=0s"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"prune should not gate on an unmerged worktree's pre-remove; stderr:\n{stderr}"
);
assert!(
stderr.contains("No merged worktrees to remove"),
"prune should report no removable worktrees; stderr:\n{stderr}"
);
assert!(
!stderr.contains("needs approval"),
"unmerged pre-remove must not be requested for approval; stderr:\n{stderr}"
);
assert!(wt_path.exists(), "unmerged worktree should remain");
}
#[rstest]
fn test_prune_non_current_removal_does_not_approve_post_switch(mut repo: TestRepo) {
repo.write_project_config(r#"post-switch = "echo primary post-switch""#);
repo.commit("Add post-switch hook");
let wt_path = repo.add_worktree("merged-no-current");
let output = repo
.wt_command()
.args(["step", "prune", "--foreground", "--min-age=0s"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"prune should not gate on post-switch for non-current removals; stderr:\n{stderr}"
);
assert!(
!stderr.contains("needs approval"),
"primary post-switch must not be requested for approval; stderr:\n{stderr}"
);
assert!(
!wt_path.exists(),
"the merged non-current worktree should be removed"
);
}
#[rstest]
fn test_prune_runs_pre_remove_hook(mut repo: TestRepo) {
use crate::common::wait_for_file_content;
let (wt_path, marker) = prune_pre_remove_setup(&mut repo);
let output = repo
.wt_command()
.args(["step", "prune", "--foreground", "--yes", "--min-age=0s"])
.output()
.unwrap();
assert!(
output.status.success(),
"wt step prune failed: {}",
String::from_utf8_lossy(&output.stderr)
);
wait_for_file_content(&marker);
assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "ran");
assert!(!wt_path.exists(), "the merged worktree should be removed");
}
#[rstest]
fn test_prune_fallback_config_race_canary(mut repo: TestRepo) {
repo.commit("initial");
let names: Vec<String> = (0..6).map(|i| format!("merged-canary-{i}")).collect();
for name in &names {
repo.add_worktree(name);
repo.run_git(&["config", &format!("branch.{name}.remote"), "origin"]);
repo.run_git(&[
"config",
&format!("branch.{name}.merge"),
&format!("refs/heads/{name}"),
]);
}
let blocked = names[3].clone();
let blocked_wt_path = repo.worktree_path(&blocked).to_path_buf();
let trash_dir = crate::common::resolve_git_common_dir(repo.root_path()).join("wt/trash");
std::fs::create_dir_all(&trash_dir).unwrap();
let staged_path = trash_dir.join(format!(
"{}-{}",
blocked_wt_path.file_name().unwrap().to_string_lossy(),
crate::common::TEST_EPOCH
));
std::fs::write(&staged_path, "blocking file to force fallback").unwrap();
let mut cmd = repo.wt_command();
#[cfg(unix)]
let branch_delete_marker = repo.home_path().join("fallback-branch-delete-started");
#[cfg(unix)]
{
let git_wrapper_dir = repo.home_path().join("git-wrapper");
std::fs::create_dir_all(&git_wrapper_dir).unwrap();
write_delaying_git_wrapper(&git_wrapper_dir, &which::which("git").unwrap());
prepend_path(&mut cmd, &git_wrapper_dir);
cmd.env("WT_PRUNE_DELAY_BRANCH", &blocked);
cmd.env("WT_PRUNE_BRANCH_DELETE_STARTED", &branch_delete_marker);
}
let output = cmd
.args(["step", "prune", "--yes", "--min-age=0s"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"prune should succeed; the old Windows fallback-path race \
failed it here with a `.git/config` permission error \
(issue #2801).\nstderr:\n{stderr}"
);
assert!(
!stderr.contains("unable to access '.git/config'"),
"fallback-path `.git/config` race fired — the fallback's \
branch deletion collided with a live integration-check \
reader (issue #2801).\nstderr:\n{stderr}"
);
#[cfg(unix)]
assert!(
branch_delete_marker.exists(),
"delayed fallback branch deletion did not run"
);
assert!(
!blocked_wt_path.exists(),
"fallback worktree removal should finish before prune exits"
);
let branches = repo.git_output(&["branch", "--format=%(refname:short)"]);
assert!(
!branches.lines().any(|branch| branch == blocked),
"fallback branch deletion should finish before prune exits; branches:\n{branches}"
);
let _ = std::fs::remove_file(&staged_path);
}
#[cfg(unix)]
fn prepend_path(cmd: &mut std::process::Command, dir: &std::path::Path) {
let (path_var_name, current_path) = std::env::vars_os()
.find(|(key, _)| key.eq_ignore_ascii_case("PATH"))
.map(|(key, value)| (key, Some(value)))
.unwrap_or_else(|| ("PATH".into(), None));
let mut paths: Vec<std::path::PathBuf> = current_path
.as_deref()
.map(std::env::split_paths)
.into_iter()
.flatten()
.collect();
paths.insert(0, dir.to_path_buf());
cmd.env(path_var_name, std::env::join_paths(paths).unwrap());
}
#[cfg(unix)]
fn write_delaying_git_wrapper(dir: &std::path::Path, real_git: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let real_git = shell_escape::unix::escape(real_git.to_string_lossy());
let script = format!(
r#"#!/bin/sh
if [ "$1" = "branch" ] && {{ [ "$2" = "-d" ] || [ "$2" = "-D" ]; }} && [ "$3" = "$WT_PRUNE_DELAY_BRANCH" ]; then
: > "$WT_PRUNE_BRANCH_DELETE_STARTED"
sleep 2
fi
exec {real_git} "$@"
"#
);
let path = dir.join("git");
std::fs::write(&path, script).unwrap();
let mut permissions = std::fs::metadata(&path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).unwrap();
}