Files
max-sixty__worktrunk/tests/integration_tests/step_relocate.rs
Worktrunk Bot f316b0cf7b fix(relocate): count template-error branches in human skip summary (#3688)
## Summary

Nightly survey finding in `src/commands/step/relocate.rs`.

`wt step relocate` classifies branches into three buckets: relocated,
validation-skipped, and template-error (a branch whose `worktree-path`
template failed to expand). The `--format=json` output folds template
errors into its `skipped` set (each as `reason: "template_error"`), but
the human-readable summary counted only validation and executor skips —
so when a valid candidate and a template-error branch coexist, the human
"skipped N" undercounted by the number of template errors.

Two call sites diverged from JSON:

- `show_all_skipped(validation_skipped.len())` (all candidates
validation-skipped, plus a template error)
- `total_skipped = validation_skipped.len() + executor.skipped_count()`
(a successful relocation alongside a template error)

Both now add `template_skips.len()`, matching the JSON skip set. The
`candidates.is_empty()` early-return path was already correct
(`show_no_relocations_needed(template_error_branches.len())`).

Impact is limited — each template-error branch already prints an
explicit `Skipping <branch> due to template error:` warning with a
diagnostic block — but the final tally should agree with
`--format=json`.

## Test

`test_relocate_template_error_counted_in_summary` sets a `worktree-path`
template that fails only for branch `bad` (a branch-gated undefined
variable) while `good` expands normally. Relocate moves `good` and skips
`bad`; the test asserts the summary reads `Relocated 1 worktree, skipped
1 worktree`. Before the fix the summary read `Relocated 1 worktree`
(skip count 0).

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-01 00:48:08 -07:00

1550 lines
51 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for `wt step relocate`
use crate::common::{
TestRepo, configure_directive_files, directive_files, make_snapshot_cmd, repo,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::path::Path;
/// Get the parent directory of the repo (where worktrees are created)
fn worktree_parent(repo: &TestRepo) -> std::path::PathBuf {
repo.root_path().parent().unwrap().to_path_buf()
}
/// Test with no mismatched worktrees
#[rstest]
fn test_relocate_no_mismatches(mut repo: TestRepo) {
// Create a worktree at the expected location
repo.add_worktree("feature");
// All worktrees should be at expected paths
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
}
/// Test relocating a single mismatched worktree
#[rstest]
fn test_relocate_single_mismatch(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree manually at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Relocate should move it to the expected path
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify the worktree was moved to expected location
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Worktree should be at expected path: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Old worktree path should no longer exist: {}",
wrong_path.display()
);
}
/// Test dry run shows what would be moved
#[rstest]
fn test_relocate_dry_run(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Dry run should show what would be moved without actually moving
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--dry-run"],
None
));
// Verify the worktree was NOT moved
assert!(
wrong_path.exists(),
"Worktree should still be at wrong path in dry run: {}",
wrong_path.display()
);
}
/// Test that locked worktrees are skipped
#[rstest]
fn test_relocate_locked_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location and lock it
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
repo.run_git(&["worktree", "lock", wrong_path.to_str().unwrap()]);
// Relocate should skip locked worktree
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify the worktree was NOT moved
assert!(
wrong_path.exists(),
"Locked worktree should not be moved: {}",
wrong_path.display()
);
}
/// Test mixed success and skip (covers "Relocated X, skipped Y" output)
#[rstest]
fn test_relocate_mixed_success_and_skip(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create one worktree that can be moved
let wrong_path1 = parent.join("wrong-location-1");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature1",
wrong_path1.to_str().unwrap(),
]);
// Create another worktree that is locked (will be skipped)
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature2",
wrong_path2.to_str().unwrap(),
]);
repo.run_git(&["worktree", "lock", wrong_path2.to_str().unwrap()]);
// Relocate should move feature1 and skip feature2
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify feature1 was moved
let expected_path1 = parent.join("repo.feature1");
assert!(
expected_path1.exists(),
"feature1 should be at expected path: {}",
expected_path1.display()
);
// Verify feature2 was NOT moved (locked)
assert!(
wrong_path2.exists(),
"Locked feature2 should not be moved: {}",
wrong_path2.display()
);
}
/// Test that existing target path causes skip
#[rstest]
fn test_relocate_target_exists(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Create a directory at the expected location
let expected_path = parent.join("repo.feature");
fs::create_dir_all(&expected_path).unwrap();
fs::write(expected_path.join("existing-file.txt"), "existing").unwrap();
// Relocate should skip because target exists
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify the worktree was NOT moved
assert!(
wrong_path.exists(),
"Worktree should not be moved when target exists: {}",
wrong_path.display()
);
}
/// Test that dirty linked worktrees relocate cleanly without --commit.
///
/// `git worktree move` carries modified-tracked and untracked files along
/// with the worktree, so there's no reason to require a clean state. Issue
/// #3103.
#[rstest]
fn test_relocate_dirty_without_commit(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Make uncommitted changes - both modified and untracked
fs::write(wrong_path.join("dirty.txt"), "untracked file").unwrap();
// Modify a tracked file too (initial test commit creates README.md or similar).
let tracked = wrong_path.join("modified-tracked.txt");
fs::write(&tracked, "first").unwrap();
repo.git_command()
.args([
"-C",
wrong_path.to_str().unwrap(),
"add",
"modified-tracked.txt",
])
.run()
.unwrap();
repo.git_command()
.args([
"-C",
wrong_path.to_str().unwrap(),
"commit",
"-m",
"add tracked",
])
.run()
.unwrap();
fs::write(&tracked, "second").unwrap();
// Relocate should move the dirty worktree
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify the worktree was moved to its expected location, carrying both
// the untracked and modified-tracked files with it.
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Dirty worktree should be moved: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Old worktree path should no longer exist: {}",
wrong_path.display()
);
assert!(
expected_path.join("dirty.txt").exists(),
"Untracked file should travel with the worktree",
);
assert_eq!(
fs::read_to_string(expected_path.join("modified-tracked.txt")).unwrap(),
"second",
"Modified tracked file content should travel with the worktree",
);
}
/// Test that a dirty main worktree is still skipped — its relocation runs
/// `git checkout <default-branch>` which refuses to switch over uncommitted
/// changes.
#[rstest]
fn test_relocate_dirty_main_worktree_skipped(repo: TestRepo) {
let parent = worktree_parent(&repo);
let repo_path = repo.root_path().to_path_buf();
// Switch main worktree to a feature branch so it becomes a relocation
// candidate (expected path = repo.feature, not repo).
repo.run_git(&["checkout", "-b", "feature"]);
// Make uncommitted changes in main worktree
fs::write(repo_path.join("dirty.txt"), "uncommitted").unwrap();
// Relocate should skip the dirty main worktree
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Main worktree stays put
let expected_path = parent.join("repo.feature");
assert!(
!expected_path.exists(),
"Dirty main worktree should not be relocated: {}",
expected_path.display()
);
}
/// Test that --commit auto-commits dirty worktrees before relocating
#[rstest]
fn test_relocate_dirty_with_commit(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Make uncommitted changes
fs::write(wrong_path.join("dirty.txt"), "uncommitted changes").unwrap();
// Configure mock LLM command via config file
let worktrunk_config = r#"
[commit.generation]
command = "cat >/dev/null && echo 'chore: auto-commit before relocate'"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
// Relocate with --commit should commit then move
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--commit"],
None
));
// Verify the worktree was moved to expected location
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Worktree should be at expected path after commit: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Old worktree path should no longer exist: {}",
wrong_path.display()
);
}
/// Create a worktree for `branch` at `path`, stopped on an unresolved
/// conflict: `git merge side` leaves the index at stages 1–3 with `<<<<<<<` on
/// disk — the state `git add -A` would silently resolve.
fn add_conflicted_worktree(repo: &TestRepo, branch: &str, path: &Path) {
// A `side` branch that edits the same line, to conflict against.
fs::write(repo.root_path().join("conflict.txt"), "base\n").unwrap();
repo.run_git(&["add", "conflict.txt"]);
repo.run_git(&["commit", "-m", "Base edit"]);
repo.run_git(&["checkout", "-b", "side"]);
fs::write(repo.root_path().join("conflict.txt"), "side\n").unwrap();
repo.run_git(&["commit", "-am", "Conflicting edit on side"]);
repo.run_git(&["checkout", "main"]);
repo.run_git(&["worktree", "add", "-b", branch, path.to_str().unwrap()]);
let git = |args: &[&str]| {
repo.git_command()
.current_dir(path)
.args(args.iter().copied())
.run()
.unwrap()
};
fs::write(path.join("conflict.txt"), "theirs\n").unwrap();
git(&["commit", "-am", "Conflicting edit on branch"]);
// Conflicts: both sides changed the same line.
git(&["merge", "side"]);
let unmerged = git(&["diff", "--name-only", "--diff-filter=U"]);
assert_eq!(
String::from_utf8_lossy(&unmerged.stdout).trim(),
"conflict.txt",
"setup must leave an unresolved conflict in the index"
);
}
/// Regression: `--commit` stages with `git add -A` before committing, and
/// `git add -A` collapses an unmerged path's index stages — resolving the
/// conflict as far as the index is concerned while `<<<<<<<` is still on disk,
/// and taking git's own refusal to commit with it. So relocate would commit
/// the markers and leave a broken merge looking finished.
#[rstest]
fn test_relocate_refuses_unmerged_paths(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
add_conflicted_worktree(&repo, "feature", &wrong_path);
let head_before = repo.head_sha_in(&wrong_path);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--commit"],
None
));
assert_eq!(
repo.head_sha_in(&wrong_path),
head_before,
"the refusal must leave HEAD alone; committing here would commit the conflict markers"
);
assert!(
wrong_path.exists() && !parent.join("repo.feature").exists(),
"the conflicted worktree must stay put rather than move on an uncommitted conflict"
);
assert!(
fs::read_to_string(wrong_path.join("conflict.txt"))
.unwrap()
.contains("<<<<<<<"),
"the conflict must be left for the user to resolve, markers intact"
);
}
/// An unresolved conflict is a per-worktree blocker the user must fix by hand,
/// like a locked worktree — so it skips that worktree with a stable JSON
/// `reason` and relocates the rest, rather than failing the whole run.
#[rstest]
fn test_relocate_unmerged_skips_only_that_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
// One worktree stopped on a conflict, one merely dirty (index clean).
let conflicted_path = parent.join("wrong-conflicted");
add_conflicted_worktree(&repo, "conflicted", &conflicted_path);
let clean_path = parent.join("wrong-clean");
repo.run_git(&[
"worktree",
"add",
"-b",
"clean",
clean_path.to_str().unwrap(),
]);
fs::write(clean_path.join("dirty.txt"), "uncommitted").unwrap();
fs::write(
repo.test_config_path(),
r#"
[commit.generation]
command = "cat >/dev/null && echo 'chore: auto-commit before relocate'"
"#,
)
.unwrap();
let output = repo
.wt_command()
.args(["step", "relocate", "--commit", "--format=json"])
.output()
.unwrap();
assert!(
output.status.success(),
"one conflicted worktree must not fail the whole run; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
let skipped = parsed["skipped"].as_array().expect("skipped array");
assert!(
skipped
.iter()
.any(|s| s["branch"] == "conflicted" && s["reason"] == "unmerged"),
"conflicted worktree should be skipped with reason \"unmerged\": {parsed}"
);
let entries = parsed["entries"].as_array().expect("entries array");
assert!(
entries.iter().any(|e| e["branch"] == "clean"),
"the dirty-but-committable sibling should still relocate: {parsed}"
);
// The conflicted worktree stayed put with its conflict; the sibling moved.
assert!(
conflicted_path.exists(),
"conflicted worktree should not move"
);
assert!(
parent.join("repo.clean").exists() && !clean_path.exists(),
"clean worktree should have relocated"
);
}
/// Test that --clobber backs up non-worktree paths at target locations
#[rstest]
fn test_relocate_clobber_backs_up(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Create a directory at the expected location (non-worktree blocker)
let expected_path = parent.join("repo.feature");
fs::create_dir_all(&expected_path).unwrap();
fs::write(expected_path.join("existing-file.txt"), "existing content").unwrap();
// Relocate with --clobber should backup and move
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--clobber"],
None
));
// Verify the worktree was moved
assert!(
expected_path.exists(),
"Worktree should be at expected location: {}",
expected_path.display()
);
assert!(
!wrong_path.exists(),
"Original path should no longer exist: {}",
wrong_path.display()
);
// Verify backup exists (with timestamp suffix)
let backup_exists = fs::read_dir(&parent)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("repo.feature.bak.")
});
assert!(backup_exists, "Backup directory should exist");
}
/// Regression: when the computed backup path already exists, relocate
/// --clobber falls back to the next free `-N` name rather than overwriting it.
/// (Matches the `wt switch --clobber` contract — see
/// test_switch_clobber_falls_back_when_backup_taken.)
#[rstest]
fn test_relocate_clobber_falls_back_when_backup_taken(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location.
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Blocker file at the expected destination.
let expected_path = parent.join("repo.feature");
fs::write(&expected_path, "blocker contents").unwrap();
// Pre-create the backup path relocate would compute. TEST_EPOCH pins the
// timestamp suffix so this name is deterministic.
// TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC
let taken = parent.join("repo.feature.bak.20250102-000000");
fs::write(&taken, "existing backup").unwrap();
let output = make_snapshot_cmd(&repo, "step", &["relocate", "--clobber"], None)
.output()
.expect("relocate should run");
assert!(
output.status.success(),
"relocate must fall back to a free backup name; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
// The worktree was moved to the expected location.
assert!(
expected_path.is_dir(),
"Worktree should be at expected location: {}",
expected_path.display()
);
// The pre-existing backup is untouched; the blocker moved to the -2 name.
assert_eq!(
fs::read_to_string(&taken).unwrap(),
"existing backup",
"existing backup must not be overwritten"
);
let fallback = parent.join("repo.feature.bak.20250102-000000-2");
assert_eq!(
fs::read_to_string(&fallback).unwrap(),
"blocker contents",
"blocker file must move to the -2 fallback name"
);
}
/// Test that --clobber refuses to clobber an existing worktree
#[rstest]
fn test_relocate_clobber_refuses_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create worktree alpha at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_path.to_str().unwrap(),
]);
// Create another worktree beta at alpha's expected location
let expected_path = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
expected_path.to_str().unwrap(),
]);
// Relocate with --clobber should still skip (can't clobber a worktree)
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "--clobber", "alpha"],
None
));
// Verify alpha was NOT moved (beta still occupies the target)
assert!(
wrong_path.exists(),
"alpha should still be at wrong location: {}",
wrong_path.display()
);
}
/// Test relocating specific worktrees by branch name
#[rstest]
fn test_relocate_specific_branch(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create two worktrees at non-standard locations
let wrong_path1 = parent.join("wrong-location-1");
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature1",
wrong_path1.to_str().unwrap(),
]);
repo.run_git(&[
"worktree",
"add",
"-b",
"feature2",
wrong_path2.to_str().unwrap(),
]);
// Relocate only feature1
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "feature1"],
None
));
// Verify only feature1 was moved
let expected_path1 = parent.join("repo.feature1");
assert!(
expected_path1.exists(),
"feature1 should be at expected path: {}",
expected_path1.display()
);
assert!(
wrong_path2.exists(),
"feature2 should still be at wrong path: {}",
wrong_path2.display()
);
}
/// Test relocating main worktree with non-default branch (create + switch)
#[rstest]
fn test_relocate_main_worktree(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Switch main worktree to a feature branch
repo.run_git(&["checkout", "-b", "feature"]);
// Relocate should create worktree for feature and switch main to default branch
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify new worktree was created
let expected_path = parent.join("repo.feature");
assert!(
expected_path.exists(),
"Feature worktree should be created at: {}",
expected_path.display()
);
// Verify main worktree is now on default branch
let output = repo
.git_command()
.args(["branch", "--show-current"])
.run()
.unwrap();
let current_branch = String::from_utf8_lossy(&output.stdout);
assert_eq!(
current_branch.trim(),
"main",
"Main worktree should be on default branch"
);
}
/// Regression: a branch literally named `-foo` (creatable via `git
/// update-ref refs/heads/-foo HEAD`) must round-trip through main worktree
/// relocation without `git worktree add` parsing the ref as a flag.
/// Without `--end-of-options`, the `worktree add` call would fail with
/// `unknown switch 'o'`.
#[rstest]
fn test_relocate_main_worktree_hyphen_prefixed_branch(repo: TestRepo) {
let parent = worktree_parent(&repo);
// `git checkout -b -- -foo` is rejected by modern git, but `update-ref`
// happily writes the ref, then `symbolic-ref` moves HEAD onto it.
repo.run_git(&["update-ref", "refs/heads/-foo", "HEAD"]);
repo.run_git(&["symbolic-ref", "HEAD", "refs/heads/-foo"]);
let output = repo
.wt_command()
.args(["step", "relocate"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"relocate must succeed with a hyphen-prefixed branch; \
stdout: {stdout}\nstderr: {stderr}"
);
let expected_path = parent.join("repo.-foo");
assert!(
expected_path.exists(),
"worktree for `-foo` should be created at: {}",
expected_path.display()
);
}
/// Test swap scenario: two worktrees at each other's expected locations
///
/// When alpha is at repo.beta and beta is at repo.alpha, relocate
/// automatically handles the swap via a temporary location.
#[rstest]
fn test_relocate_swap(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create worktrees at each other's expected locations
// alpha at repo.beta (where beta should go)
// beta at repo.alpha (where alpha should go)
let path_for_beta = parent.join("repo.beta");
let path_for_alpha = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
path_for_beta.to_str().unwrap(), // alpha at beta's expected location
]);
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
path_for_alpha.to_str().unwrap(), // beta at alpha's expected location
]);
// Relocate resolves the swap via temp location
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify both are now at their expected locations
assert!(path_for_alpha.exists(), "alpha should be at repo.alpha");
assert!(path_for_beta.exists(), "beta should be at repo.beta");
}
/// A worktree whose target is occupied by a *blocked* worktree must itself be
/// skipped, not temp-moved.
///
/// Regression: `beta` sits at `alpha`'s target, so `alpha` depends on `beta`
/// vacating — but `beta`'s own target is a plain non-worktree file with no
/// `--clobber`, so `beta` is blocked and never moves. Previously the no-progress
/// branch treated `alpha` as a cycle, temp-moved it into the staging dir, and
/// `finalize` then failed moving it into the still-occupied target — erroring
/// out and stranding `alpha` in `.git/wt/staging/relocate/`.
#[rstest]
fn test_relocate_blocked_occupant_skips_dependent(repo: TestRepo) {
let parent = worktree_parent(&repo);
// beta occupies alpha's expected path (repo.alpha).
let path_alpha = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
path_alpha.to_str().unwrap(),
]);
// alpha lives at a non-standard location and wants repo.alpha.
let wrong_alpha = parent.join("wrong-alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_alpha.to_str().unwrap(),
]);
// Block beta's target (repo.beta) with a plain, non-worktree directory.
let path_beta = parent.join("repo.beta");
fs::create_dir_all(&path_beta).unwrap();
fs::write(path_beta.join("blocker.txt"), "blocker").unwrap();
// Both are skipped; the command must succeed and strand nothing.
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "alpha", "beta"],
None
));
// alpha stays at its original location (not stranded in staging).
assert!(
wrong_alpha.exists(),
"alpha should remain at its original location: {}",
wrong_alpha.display()
);
// beta stays where it was (still occupying repo.alpha).
assert!(path_alpha.exists(), "beta should remain at repo.alpha");
// Nothing left behind in the staging dir.
let stranded = repo.root_path().join(".git/wt/staging/relocate/alpha");
assert!(
!stranded.exists(),
"alpha must not be stranded in the staging dir: {}",
stranded.display()
);
}
/// A blocked occupant must propagate transitively down a chain of dependents,
/// across multiple resolution passes.
///
/// Extends `test_relocate_blocked_occupant_skips_dependent` to a 3-level chain
/// (`alpha → beta → gamma-blocked`) that specifically exercises the loop's
/// `made_progress` re-drive. `gamma`'s target is a plain non-worktree directory
/// (no `--clobber`), so `gamma` is blocked at construction; `beta` occupies
/// `gamma`'s dependency (sits at repo.beta) and `alpha` occupies `beta`'s (sits
/// at repo.alpha), so the block can only reach `alpha` one pass after it reaches
/// `beta`.
///
/// The re-drive is only load-bearing when a dependent is *iterated before* its
/// occupant within a pass. Worktrees are processed in `git worktree list` order
/// (git sorts linked worktrees by registration id ≈ path basename), independent
/// of the argument order — so `alpha` is parked at `aaa-alpha`, whose basename
/// sorts before `beta`'s `repo.alpha`. That makes pass 1 visit `alpha` while
/// `beta` is still pending (no block yet → `alpha` stays pending), then block
/// `beta` (occupant `gamma` already blocked). Only the `made_progress` re-drive
/// runs a pass 2 that sees `beta` blocked and blocks `alpha` in turn. Drop the
/// re-drive and pass 1 falls straight into `break_cycle`, which temp-moves the
/// still-pending `alpha` and `finalize` then misplaces it into the occupied
/// `repo.alpha` — the exact bug this guards. (Parking `alpha` at a path that
/// sorts *after* `repo.alpha` collapses the chain into a single pass and no
/// longer tests the re-drive; see the 2-level test.)
#[rstest]
fn test_relocate_blocked_occupant_skips_chain(repo: TestRepo) {
let parent = worktree_parent(&repo);
// alpha lives at aaa-alpha (basename sorts before repo.alpha, so alpha is
// iterated before its occupant beta) and wants repo.alpha.
let wrong_alpha = parent.join("aaa-alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_alpha.to_str().unwrap(),
]);
// beta occupies alpha's expected path (repo.alpha) and wants repo.beta.
let path_alpha = parent.join("repo.alpha");
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
path_alpha.to_str().unwrap(),
]);
// gamma occupies beta's expected path (repo.beta) and wants repo.gamma.
let path_beta = parent.join("repo.beta");
repo.run_git(&[
"worktree",
"add",
"-b",
"gamma",
path_beta.to_str().unwrap(),
]);
// Block gamma's target (repo.gamma) with a plain, non-worktree directory.
let path_gamma = parent.join("repo.gamma");
fs::create_dir_all(&path_gamma).unwrap();
fs::write(path_gamma.join("blocker.txt"), "blocker").unwrap();
// All three are skipped; the command must succeed and strand nothing.
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "alpha", "beta", "gamma"],
None
));
// Every worktree stays put — none stranded in staging or misplaced into an
// occupied target.
assert!(
wrong_alpha.exists(),
"alpha should remain at its original location: {}",
wrong_alpha.display()
);
assert!(path_alpha.exists(), "beta should remain at repo.alpha");
assert!(path_beta.exists(), "gamma should remain at repo.beta");
let stranded = repo.root_path().join(".git/wt/staging/relocate/alpha");
assert!(
!stranded.exists(),
"alpha must not be stranded in the staging dir: {}",
stranded.display()
);
let misplaced = path_alpha.join("alpha");
assert!(
!misplaced.exists(),
"alpha must not be misplaced inside beta's worktree: {}",
misplaced.display()
);
}
/// Test relocating multiple worktrees shows compact output
#[rstest]
fn test_relocate_multiple(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create 5 worktrees at non-standard locations
for i in 1..=5 {
let wrong_path = parent.join(format!("wrong-{i}"));
repo.run_git(&[
"worktree",
"add",
"-b",
&format!("feature-{i}"),
wrong_path.to_str().unwrap(),
]);
}
// Relocate all
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
// Verify all were moved
for i in 1..=5 {
let expected_path = parent.join(format!("repo.feature-{i}"));
assert!(
expected_path.exists(),
"feature-{i} should be at expected path: {}",
expected_path.display()
);
}
}
/// Test that two worktrees targeting the same path doesn't panic
///
/// Before the fix, this would panic with "existing target must be a tracked worktree"
/// because after the first worktree moved, the second would find an occupied target
/// that wasn't in the tracking map.
#[rstest]
fn test_relocate_same_target_no_panic(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create two worktrees at non-standard locations
let wrong_path1 = parent.join("wrong-location-1");
let wrong_path2 = parent.join("wrong-location-2");
repo.run_git(&[
"worktree",
"add",
"-b",
"alpha",
wrong_path1.to_str().unwrap(),
]);
repo.run_git(&[
"worktree",
"add",
"-b",
"beta",
wrong_path2.to_str().unwrap(),
]);
// Configure a template that maps BOTH branches to the same path
// This creates the "same target" scenario
let worktrunk_config = r#"
worktree-path = "{{ repo }}.shared"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
// Relocate only alpha and beta (exclude any other branches from prior tests)
// Previously this would panic
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "alpha", "beta"],
None
));
// Verify first worktree moved to shared location
// Note: {{ repo }} in template uses repo NAME, so path is inside repo root
let shared_path = repo.root_path().join("repo.shared");
assert!(
shared_path.exists(),
"First worktree should be at shared path: {}",
shared_path.display()
);
// Second worktree should still be at its original location (skipped)
// It was skipped because the target was occupied after first moved there
assert!(
wrong_path1.exists() || wrong_path2.exists(),
"One worktree should remain at original location (skipped)"
);
}
/// Test that template expansion errors are reported gracefully
#[rstest]
fn test_relocate_template_error(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Configure an invalid template with a non-existent variable
let worktrunk_config = r#"
worktree-path = "{{ nonexistent_variable }}"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
// Relocate should warn about template error and skip
// Filter to just "feature" to avoid noise from other worktrees
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["relocate", "feature"],
None
));
// Verify the worktree was NOT moved (skipped due to template error)
assert!(
wrong_path.exists(),
"Worktree should not be moved when template fails: {}",
wrong_path.display()
);
}
/// Regression test: the human-readable summary count must include
/// template-error branches, matching the `--format=json` skip set.
///
/// When a valid candidate and a template-error branch coexist, the JSON path
/// folds template errors into `all_skipped` but the human summary previously
/// counted only validation/executor skips, undercounting by the number of
/// template errors. The template here fails only for branch `bad` (via a
/// branch-gated undefined variable) while `good` expands cleanly, so relocate
/// moves `good` and skips `bad`.
#[rstest]
fn test_relocate_template_error_counted_in_summary(repo: TestRepo) {
let parent = worktree_parent(&repo);
// `good`: a mismatched worktree that will relocate successfully.
let good_wrong = parent.join("good-wrong");
repo.run_git(&[
"worktree",
"add",
"-b",
"good",
good_wrong.to_str().unwrap(),
]);
// `bad`: a worktree whose template expansion fails.
let bad_path = parent.join("bad-loc");
repo.run_git(&["worktree", "add", "-b", "bad", bad_path.to_str().unwrap()]);
// Template errors only for branch `bad`; `good` renders the standard path.
let worktrunk_config = "worktree-path = \"{% if branch == 'bad' %}{{ undefined_var }}{% endif %}{{ repo_path }}/../{{ repo }}.{{ branch }}\"\n";
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
let output = repo
.wt_command()
.args(["step", "relocate"])
.output()
.unwrap();
assert!(output.status.success(), "relocate should succeed");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Relocated 1 worktree, skipped 1 worktree"),
"summary must count the template-error branch as skipped; stderr was:\n{stderr}"
);
// `good` relocated to its expected sibling path; `bad` untouched.
assert!(
parent.join("repo.good").exists(),
"good should have relocated"
);
assert!(
bad_path.exists(),
"bad should be untouched (template error)"
);
}
/// Regression test: main worktree relocation must surface a failed
/// `git checkout <default_branch>` rather than silently claiming success.
///
/// Setup engineers a state where `worktrunk.default-branch` is set to a
/// branch that does not exist locally. `Repository::default_branch()`
/// trusts the persisted value (validation happens downstream), so
/// `wt step relocate` proceeds into `move_main_worktree`, which tries
/// `git checkout <nonexistent-branch>`. Before the fix, `Cmd::run()`
/// returned `Ok(Output { status: non-zero, .. })` and the `?` operator
/// didn't propagate it, so relocate printed "Relocated main ..." even
/// though nothing happened.
///
/// After the fix: non-zero exit bails with the git stderr, exit code is
/// non-zero, and the main worktree stays at its original path.
#[rstest]
fn test_relocate_main_worktree_checkout_failure_surfaces(repo: TestRepo) {
let parent = worktree_parent(&repo);
let repo_path = repo.root_path().to_path_buf();
// Switch main worktree to a non-default branch so it becomes a
// relocation candidate (expected path = repo.feature, not repo).
repo.run_git(&["checkout", "-b", "feature"]);
// Point worktrunk's default-branch cache at a branch that doesn't
// resolve locally. `default_branch()` now returns this value without
// validating it, so relocate's preflight does NOT bail and the main
// worktree code path runs `git checkout nonexistent-branch-xyz`.
repo.run_git(&[
"config",
"worktrunk.default-branch",
"nonexistent-branch-xyz",
]);
let output = repo
.wt_command()
.args(["step", "relocate"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"relocate must fail when checkout of default branch fails; \
stdout: {stdout}\nstderr: {stderr}"
);
assert!(
!stderr.contains("Relocated"),
"relocate must not claim success after a failed checkout; \
stdout: {stdout}\nstderr: {stderr}"
);
// Main worktree is untouched - still at repo_path, still on feature.
assert!(
repo_path.exists(),
"main worktree path should still exist: {}",
repo_path.display()
);
let expected_path = parent.join("repo.feature");
assert!(
!expected_path.exists(),
"relocate must not create the new worktree path after checkout \
failure: {}",
expected_path.display()
);
let branch_output = repo
.git_command()
.args(["branch", "--show-current"])
.run()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&branch_output.stdout).trim(),
"feature",
"main worktree branch should be unchanged after failed checkout"
);
}
/// Test that empty default branch is detected early with actionable error.
///
/// Engineers a state where detection genuinely fails (no remote, no
/// standard branch names, no init.defaultBranch) so `default_branch()`
/// returns None — relocate's preflight bails with a clear setup hint.
#[rstest]
fn test_relocate_empty_default_branch(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Create a worktree at a non-standard location on a branch with a
// non-standard name, then rename `main` to another non-standard name
// and remove the remote. With no remote, no main/master/develop/trunk,
// and no init.defaultBranch, detection has nothing to go on.
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
repo.run_git(&["branch", "-m", "main", "trunk-a"]);
repo.run_git(&["remote", "remove", "origin"]);
// Relocate should fail early with helpful error
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["relocate"], None));
}
/// `step relocate --dry-run --format=json` lists planned moves with from/to paths.
#[rstest]
fn test_relocate_dry_run_json(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let output = repo
.wt_command()
.args(["step", "relocate", "--dry-run", "--format=json"])
.output()
.unwrap();
assert!(
output.status.success(),
"relocate dry-run JSON should succeed"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(parsed["dry_run"], true);
let entries = parsed["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["branch"], "feature");
assert!(
entries[0]["from"]
.as_str()
.unwrap()
.ends_with("wrong-location")
);
assert!(entries[0]["to"].as_str().unwrap().ends_with("repo.feature"));
assert_eq!(
parsed["skipped"].as_array().expect("skipped array").len(),
0
);
// Dry run did not move
assert!(wrong_path.exists());
}
/// `step relocate --format=json` after execution emits per-branch records and
/// distinguishes relocated vs skipped (with stable `reason` codes).
#[rstest]
fn test_relocate_json_with_skip(repo: TestRepo) {
let repo_path = repo.root_path().to_path_buf();
// Switch the main worktree to a feature branch so it becomes a relocation
// candidate, then dirty it. A dirty main worktree is skipped because its
// relocation runs `git checkout`, which won't switch over dirty state.
repo.run_git(&["checkout", "-b", "feature"]);
fs::write(repo_path.join("dirty.txt"), "uncommitted").unwrap();
let output = repo
.wt_command()
.args(["step", "relocate", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "relocate JSON should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(parsed["dry_run"], false);
let entries = parsed["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 0);
let skipped = parsed["skipped"].as_array().expect("skipped array");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0]["branch"], "feature");
assert_eq!(skipped[0]["reason"], "uncommitted");
}
/// `step relocate --format=json` after a successful execution emits the
/// per-branch `entries` array with `from` / `to` paths.
#[rstest]
fn test_relocate_executes_json(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let output = repo
.wt_command()
.args(["step", "relocate", "--format=json"])
.output()
.unwrap();
assert!(
output.status.success(),
"relocate JSON should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(parsed["dry_run"], false);
let entries = parsed["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["branch"], "feature");
assert!(
entries[0]["from"]
.as_str()
.unwrap()
.ends_with("wrong-location")
);
assert!(entries[0]["to"].as_str().unwrap().ends_with("repo.feature"));
// The actual move should have happened.
assert!(!wrong_path.exists());
assert!(parent.join("repo.feature").exists());
}
/// `step relocate --format=json` surfaces template-expansion failures as
/// `skipped` entries with `reason: "template_error"` rather than silently
/// reporting an empty success — automation needs to detect a broken config.
#[rstest]
fn test_relocate_template_error_json(repo: TestRepo) {
let parent = worktree_parent(&repo);
// Add a worktree so something exists to evaluate the template against.
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
// Reference an undefined template variable to force expansion failure.
let worktrunk_config = r#"
worktree-path = "../{{ undefined_var }}.{{ branch }}"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
let output = repo
.wt_command()
.args(["step", "relocate", "--format=json"])
.output()
.unwrap();
assert!(output.status.success(), "relocate JSON should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let skipped = parsed["skipped"].as_array().expect("skipped array");
assert!(
skipped.iter().any(|s| s["reason"] == "template_error"),
"template_error skip missing from JSON: {parsed}"
);
}
/// Relocating a worktree the user is standing inside preserves their
/// subdirectory position, routing the `cd` through the same
/// `resolve_subdir_in_target` helper as `switch`/`remove` (issue #3343 unify).
///
/// Ignored on Windows: subdir preservation only fires when the cwd is inside the
/// moving worktree — which is exactly when `git worktree move` (a directory
/// rename) fails with a sharing violation, because a live process holds that cwd.
/// Unlike `remove` (where shell integration cds to main before removing), a real
/// Windows user hits this too: their shell holds the cwd across the move. So the
/// preservation path is reachable, and testable, only on Unix.
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_relocate_preserves_subdir(repo: TestRepo) {
let parent = worktree_parent(&repo);
let (cd_path, exec_path, _guard) = directive_files();
// Create a worktree at a non-standard location, with a subdirectory the
// user is working in.
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let subdir = Path::new("apps").join("gateway");
fs::create_dir_all(wrong_path.join(&subdir)).unwrap();
let mut cmd = repo.wt_command();
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["step", "relocate"])
.current_dir(wrong_path.join(&subdir));
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt step relocate failed: {output:?}"
);
// The cd directive should land in the equivalent subdirectory of the
// worktree's new location, not at its root.
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
let expected_subdir = parent.join("repo.feature").join(&subdir);
let expected_str = expected_subdir.to_string_lossy();
assert!(
cd_content.contains(&*expected_str),
"CD file should contain relocated subdirectory path {expected_str}, got: {cd_content}"
);
}
/// A shell started before the split directive protocol cannot follow a
/// relocated current worktree. The relocation still succeeds, but it must
/// explain that the wrapper is stale and how to repair it rather than silently
/// leaving the shell in the renamed-away directory.
///
/// Ignored on Windows for the same reason as the adjacent subdirectory test:
/// relocating a worktree while this process holds its cwd there fails with a
/// sharing violation before the directive-warning path is reachable.
#[rstest]
#[cfg_attr(windows, ignore)]
fn test_relocate_current_with_retired_wrapper_warns(repo: TestRepo) {
let parent = worktree_parent(&repo);
let wrong_path = parent.join("wrong-location");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
wrong_path.to_str().unwrap(),
]);
let directive_dir = tempfile::TempDir::new().unwrap();
let retired_path = directive_dir.path().join("directive");
fs::write(&retired_path, "").unwrap();
let output = repo
.wt_command()
.env("WORKTRUNK_DIRECTIVE_FILE", &retired_path)
.args(["step", "relocate"])
.current_dir(&wrong_path)
.output()
.unwrap();
assert!(
output.status.success(),
"relocation should still succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!wrong_path.exists(), "the old worktree path should be gone");
assert!(
parent.join("repo.feature").exists(),
"the worktree should reach its expected path"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("shell wrapper is out of date"),
"the stale wrapper must not fail silently:\n{stderr}"
);
assert!(
stderr.contains("wt config shell install"),
"the warning must include the repair action:\n{stderr}"
);
assert_eq!(
fs::read_to_string(&retired_path).unwrap(),
"",
"wt must never write to the retired directive file"
);
}
/// An argument naming no worktree is an error. Matching it against branch names
/// alone left a typo filtering everything out, and the empty result rendered as
/// "All worktrees are at expected paths" — a success message for a no-op.
#[rstest]
fn step_relocate_rejects_unknown_worktree(repo: TestRepo) {
let output = repo
.wt_command()
.args(["step", "relocate", "--dry-run", "no-such-worktree"])
.output()
.unwrap();
assert!(!output.status.success(), "an unknown argument should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No branch or worktree named"),
"expected an unmatched-selector error, got: {stderr}"
);
}
/// A detached worktree can be named by path but has no expected path to move
/// to — the `worktree-path` template is written over the branch name. Naming
/// one is an error rather than an empty filter reported as success.
#[rstest]
fn step_relocate_rejects_detached_worktree(mut repo: TestRepo) {
repo.add_worktree("feature-detached");
repo.detach_head_in_worktree("feature-detached");
let output = repo
.wt_command()
.args(["step", "relocate", "--dry-run", "../repo.feature-detached"])
.output()
.unwrap();
assert!(!output.status.success(), "a detached worktree should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("detached"),
"expected a detached-worktree error, got: {stderr}"
);
}
/// A worktree whose directory is gone still resolves by branch, but git has
/// marked it prunable and there is nothing left to move. Naming one is an error
/// rather than an empty filter reported as success.
#[rstest]
fn step_relocate_rejects_prunable_worktree(mut repo: TestRepo) {
let worktree_path = repo.add_worktree("feature-gone");
fs::remove_dir_all(&worktree_path).unwrap();
let output = repo
.wt_command()
.args(["step", "relocate", "--dry-run", "feature-gone"])
.output()
.unwrap();
assert!(!output.status.success(), "a prunable worktree should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("directory is gone"),
"expected a prunable-worktree error, got: {stderr}"
);
}