From 52d072ce762d60841bd21b96705fc621b94b7b73 Mon Sep 17 00:00:00 2001 From: rohoswagger Date: Tue, 11 Aug 2026 14:57:17 -0700 Subject: [PATCH 1/2] fix: never strand a merged branch when the post-merge restack fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ez merge` removed the branch from stack.json and saved, then restacked the rest of the stack, then cleaned up the merged branch — with the restack behind a `?`. A restack failure therefore skipped cleanup after the entry was already gone, leaving a branch and worktree that no command can see: `ez sync` builds its cleanup candidates from `state.branches`, so an untracked branch is never a candidate. It reports "Everything is up to date" while the litter sits there. Two fixes: 1. Clean up before restacking, in both the sequential and native-stack paths. The branch is merged and its entry is already gone; whether some sibling rebases cleanly has no bearing on deleting it. 2. Give `ez sync` a safety net for orphans that already exist, or that some future ordering bug creates. `prune_orphaned_ez_worktrees` removes the worktree, branch, and remote branch for untracked branches that are merged. The safety net is deliberately narrow: only worktrees under `.worktrees/`, which ez creates and owns. External worktrees and plain local branches are left alone however merged they look, and an orphan that is not provably merged is reported with a `ez track` hint rather than deleted — an orphan with unmerged commits is lost work, not litter. --- src/cmd/merge.rs | 101 ++++++++++++++++++++++++++---- src/cmd/sync.rs | 156 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 11 deletions(-) diff --git a/src/cmd/merge.rs b/src/cmd/merge.rs index 6be006e..072ae6d 100644 --- a/src/cmd/merge.rs +++ b/src/cmd/merge.rs @@ -331,6 +331,17 @@ fn merge_native_stack( move_to_main_root_for_targets(targets, worktree_map, current_dir, main_root)?; + // Same ordering rule as the sequential path: the merged branches are cleaned up before the + // restack, so a restack failure cannot strand them without metadata. + for target in targets { + cleanup_merged_branch( + state, + &target.branch, + worktree_map.get(&target.branch).map(String::as_str), + &trunk, + )?; + } + let fetch_remote = state.fetch_remote().to_string(); let push_remote = state.remote.clone(); let (restacked, pushed) = fetch_restack_and_push_remaining(state, &fetch_remote, &push_remote)?; @@ -344,15 +355,6 @@ fn merge_native_stack( )); } - for target in targets { - cleanup_merged_branch( - state, - &target.branch, - worktree_map.get(&target.branch).map(String::as_str), - &trunk, - )?; - } - Ok(NativeMergeOutcome { status, branches, @@ -423,11 +425,15 @@ fn merge_branch( main_root, )?; + // Clean up before restacking the rest of the stack. The branch is already merged and its + // stack entry is already gone, so nothing about deleting it depends on whether some sibling + // rebases cleanly — and if the restack fails, a `?` here would strand the branch and its + // worktree with no metadata left for `ez sync` to find them by. + cleanup_merged_branch(state, branch, linked_worktree, &trunk)?; + let (restacked, restacked_for_push) = fetch_restack_and_push_remaining(state, &fetch_remote, &push_remote)?; - cleanup_merged_branch(state, branch, linked_worktree, &trunk)?; - Ok(MergeOutcome { branch: branch.to_string(), pr_number, @@ -1248,6 +1254,79 @@ exit 0 ); } + #[test] + fn merged_branch_is_cleaned_up_even_when_the_post_merge_restack_fails() { + let _guard = take_env_lock(); + let branch = "feat/linked"; + let pr_number = 42; + let merge_repo = init_merge_repo("merge-cleanup-before-restack", branch, pr_number); + + // A sibling that cannot be replayed onto the new trunk: both touch the same line. + run_cmd(&merge_repo.repo, "git", &["checkout", "main"]); + let main_head = { + let _cwd = CwdGuard::enter(&merge_repo.repo); + git::rev_parse("main").expect("main head") + }; + run_cmd(&merge_repo.repo, "git", &["checkout", "-b", "feat/sibling"]); + write_file(&merge_repo.repo, "clash.txt", "sibling\n"); + run_cmd(&merge_repo.repo, "git", &["add", "clash.txt"]); + run_cmd(&merge_repo.repo, "git", &["commit", "-m", "sibling"]); + run_cmd( + &merge_repo.repo, + "git", + &["push", "-u", "origin", "feat/sibling"], + ); + + run_cmd(&merge_repo.repo, "git", &["checkout", "main"]); + write_file(&merge_repo.repo, "clash.txt", "trunk\n"); + run_cmd(&merge_repo.repo, "git", &["add", "clash.txt"]); + run_cmd(&merge_repo.repo, "git", &["commit", "-m", "trunk clash"]); + run_cmd(&merge_repo.repo, "git", &["push", "origin", "main"]); + + { + let _cwd = CwdGuard::enter(&merge_repo.repo); + let mut state = StackState::load().expect("load state"); + state.add_branch("feat/sibling", "main", &main_head, None, None); + state.save().expect("save state"); + } + + let (fake_dir, gh_log) = + install_logging_fake_gh("merge-cleanup-before-restack-gh", pr_number); + let _path = PathGuard::install(&fake_dir); + unsafe { + std::env::set_var("GH_LOG", &gh_log); + } + let _cwd = CwdGuard::enter(&merge_repo.worktree); + + let result = run("squash", true, false); + + assert!( + result.is_err(), + "the conflicting sibling should still surface as a restack failure" + ); + + // ...but the merge itself is done, so its branch must not be left behind with no stack + // entry to find it by. That combination is unrecoverable: `ez sync` only walks tracked + // branches, so an orphan here would never be cleaned up again. + assert!( + !merge_repo.worktree.exists(), + "merged branch's worktree should be removed despite the restack failure" + ); + assert_eq!( + cmd_output(&merge_repo.repo, "git", &["branch", "--list", branch]), + "", + "merged branch should be deleted despite the restack failure" + ); + let state = { + let _main_cwd = CwdGuard::enter(&merge_repo.repo); + StackState::load().expect("load state") + }; + assert!( + !state.branches.contains_key(branch), + "merged branch should be out of stack state" + ); + } + #[test] fn merge_rejects_dirty_target_worktree_before_github_merge() { let _guard = take_env_lock(); diff --git a/src/cmd/sync.rs b/src/cmd/sync.rs index 2fbb56b..135279b 100644 --- a/src/cmd/sync.rs +++ b/src/cmd/sync.rs @@ -61,6 +61,116 @@ fn skipped_native_stack_receipt(component: &SkippedNativeStackComponent) -> serd }) } +/// Worktrees ez created that no longer have a stack entry pointing at them. +/// +/// A branch normally leaves the stack and its worktree in the same step. When something fails in +/// between — a merge that drops the entry and then hits a restack error before cleanup — the +/// worktree survives with no metadata left to find it by, so neither the cleanup loop above (which +/// only walks `state.branches`) nor `ez log` will ever mention it again. +/// +/// Scope is deliberately narrow: only worktrees under `.worktrees/`, which ez creates and owns. +/// A branch checked out in an external worktree, or a plain local branch with no worktree at all, +/// is somebody else's and is left alone no matter how merged it looks. +fn orphaned_ez_worktrees( + state: &StackState, + worktree_map: &std::collections::HashMap, +) -> Vec<(String, String)> { + let mut orphans: Vec<(String, String)> = worktree_map + .iter() + .filter(|(branch, path)| { + !state.branches.contains_key(*branch) + && !state.is_trunk(branch) + && path.contains("/.worktrees/") + }) + .map(|(branch, path)| (branch.clone(), path.clone())) + .collect(); + orphans.sort(); + orphans +} + +/// Delete the worktree, branch, and remote branch for orphans whose work already landed on trunk. +/// +/// Returns the branches it cleaned. Anything that is not provably merged is left untouched and +/// reported, because an orphan with unmerged commits is lost work, not litter. +fn prune_orphaned_ez_worktrees( + state: &StackState, + worktree_map: &std::collections::HashMap, + fetch_remote: &str, + force: bool, +) -> Vec { + let orphans = orphaned_ez_worktrees(state, worktree_map); + if orphans.is_empty() { + return Vec::new(); + } + + let branch_refs: Vec<&str> = orphans.iter().map(|(branch, _)| branch.as_str()).collect(); + let pr_statuses = + github::get_pr_statuses_for(fetch_remote, state.repo.as_deref(), &branch_refs); + + let mut cleaned = Vec::new(); + for (branch, path) in &orphans { + let pr_info = pr_statuses.get(branch.as_str()); + // A PR is authoritative when there is one; fall back to git for orphans that never had + // one. A closed-but-unmerged PR is not merged — that branch keeps its work. + let merged = match pr_info { + Some(pr) => pr.merged, + None => git::is_ancestor(branch, &state.trunk), + }; + if !merged { + ui::warn(&format!( + "`{branch}` has a worktree at `{path}` but is not tracked by ez and is not merged" + )); + ui::hint(&format!( + "Run `ez track {branch}` to manage it again, or `ez worktree delete {branch} --force` to discard it" + )); + ui::receipt(&serde_json::json!({ + "cmd": "sync", + "branch": branch, + "action": "cleanup_skipped", + "reason": "orphaned_worktree_unmerged", + "worktree": path, + })); + continue; + } + + let removed = if force { + git::worktree_remove_force(path) + } else { + git::worktree_remove(path) + }; + if let Err(e) = removed { + ui::warn(&format!( + "Could not remove orphaned worktree at `{path}`: {e}" + )); + ui::hint("Use `ez sync --force` to discard uncommitted changes"); + ui::receipt(&serde_json::json!({ + "cmd": "sync", + "branch": branch, + "action": "cleanup_skipped", + "reason": "orphaned_worktree_remove_failed", + "worktree": path, + })); + continue; + } + + let _ = git::delete_branch(branch, true); + let _ = git::delete_remote_branch(&state.remote, branch); + ui::info(&format!( + "Cleaned up orphaned worktree for `{branch}` (merged, no longer tracked)" + )); + ui::receipt(&serde_json::json!({ + "cmd": "sync", + "branch": branch, + "action": "cleaned", + "reason": "orphaned_merged_worktree", + "worktree": path, + })); + cleaned.push(branch.clone()); + } + + cleaned +} + fn reconcile_native_stacks(state: &StackState, repair_native_stack: bool) -> Result<()> { if state.is_fork_workflow() { let outcome = github::NativeStackOutcome::NotApplicable { @@ -682,6 +792,16 @@ fn run_sync_inner(force: bool, repair_native_stack: bool) -> Result<()> { cleaned.push(branch_name.clone()); } + // Safety net for worktrees whose stack entry disappeared without them — see + // `prune_orphaned_ez_worktrees`. Runs after the tracked pass so a branch cleaned above is + // already out of `state.branches` and is not considered twice. + cleaned.extend(prune_orphaned_ez_worktrees( + &state, + &worktree_map, + &fetch_remote, + force, + )); + let order = state.topo_order(); let candidates = crate::cmd::preflight::restack_candidates(&state, &order); let preflight_error = crate::cmd::preflight::run("sync", force, &candidates).err(); @@ -749,6 +869,42 @@ mod tests { let _ = std::mem::size_of_val(&f); } + #[test] + fn orphaned_ez_worktrees_only_claims_untracked_branches_in_ez_owned_worktrees() { + let mut state = StackState::new("main".to_string()); + state.add_branch("feat/tracked", "main", "aaa", None, None); + + let worktrees = std::collections::HashMap::from([ + // Orphan: ez created the worktree, but nothing in the stack points at it anymore. + ( + "feat/orphan".to_string(), + "/repo/.worktrees/feat-orphan".to_string(), + ), + // Still tracked — the normal cleanup loop owns this one. + ( + "feat/tracked".to_string(), + "/repo/.worktrees/feat-tracked".to_string(), + ), + // Someone else's worktree (Superconductor, a manual `git worktree add`). Not ours. + ( + "feat/external".to_string(), + "/elsewhere/feat-external".to_string(), + ), + // Trunk checked out in a second worktree is never litter. + ("main".to_string(), "/repo/.worktrees/main".to_string()), + ]); + + let orphans = orphaned_ez_worktrees(&state, &worktrees); + + assert_eq!( + orphans, + vec![( + "feat/orphan".to_string(), + "/repo/.worktrees/feat-orphan".to_string() + )] + ); + } + #[test] fn cleanup_candidate_branches_excludes_local_unmanaged_branches() { let managed = vec![ From c0bb5e388d2bb6f0f59ad84bc1400dd362227e56 Mon Sep 17 00:00:00 2001 From: rohoswagger Date: Tue, 11 Aug 2026 15:11:47 -0700 Subject: [PATCH 2/2] fix: require git corroboration before pruning an orphaned worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the orphan-cleanup path added in the previous commit. An orphan has no recorded PR number, so the only way to find its PR is `get_pr_statuses_for`, which queries `headRefName` and takes the most recently created match. That resolves by branch *name*, not by head SHA or head repo. Trusting its `merged` flag alone meant a recycled branch name — or, in a fork workflow, another contributor's merged `feat/login` — authorized deleting a worktree and force-deleting a branch that still held unpushed commits. The tracked cleanup path avoids this by looking PRs up by number; that is not available here, so git is the corroboration instead: the tip must already be in trunk, or the branch's diff against trunk must be empty (squash merge). Also from review: - Stop deleting the remote branch. The evidence is about the local tip and says nothing about commits pushed from elsewhere, and sync's tracked cleanup does not delete remote refs either — only `ez delete` and `ez merge` do. - Anchor worktree ownership to `{main_root}/.worktrees/` instead of a bare `contains("/.worktrees/")`, which claimed every sibling worktree when the repo itself lives under a `.worktrees/` directory. - Move out of the worktree before removing it when it contains the cwd, as the tracked loop already does; otherwise the rest of the sync runs from a deleted directory. - Re-read `git worktree list` inside the prune rather than reusing the map captured before the cleanup loop. A branch cleaned by the tracked pass is out of `state.branches` and its worktree is gone, so the stale entry matched every orphan rule and produced a spurious warning on the happy path. --- src/cmd/sync.rs | 199 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 181 insertions(+), 18 deletions(-) diff --git a/src/cmd/sync.rs b/src/cmd/sync.rs index 135279b..0d2ac4b 100644 --- a/src/cmd/sync.rs +++ b/src/cmd/sync.rs @@ -68,19 +68,23 @@ fn skipped_native_stack_receipt(component: &SkippedNativeStackComponent) -> serd /// worktree survives with no metadata left to find it by, so neither the cleanup loop above (which /// only walks `state.branches`) nor `ez log` will ever mention it again. /// -/// Scope is deliberately narrow: only worktrees under `.worktrees/`, which ez creates and owns. -/// A branch checked out in an external worktree, or a plain local branch with no worktree at all, -/// is somebody else's and is left alone no matter how merged it looks. +/// Scope is deliberately narrow: only worktrees under *this repo's* `.worktrees/`, which ez +/// creates and owns. A branch checked out in an external worktree, or a plain local branch with +/// no worktree at all, is somebody else's and is left alone no matter how merged it looks. fn orphaned_ez_worktrees( state: &StackState, + main_root: &str, worktree_map: &std::collections::HashMap, ) -> Vec<(String, String)> { + // Anchored to the repo root, not a bare `contains`: a checkout that itself lives under some + // `.worktrees/` directory would otherwise make every external worktree look ez-owned. + let owned_prefix = format!("{}/.worktrees/", main_root.trim_end_matches('/')); let mut orphans: Vec<(String, String)> = worktree_map .iter() .filter(|(branch, path)| { !state.branches.contains_key(*branch) && !state.is_trunk(branch) - && path.contains("/.worktrees/") + && path.starts_with(&owned_prefix) }) .map(|(branch, path)| (branch.clone(), path.clone())) .collect(); @@ -88,17 +92,35 @@ fn orphaned_ez_worktrees( orphans } -/// Delete the worktree, branch, and remote branch for orphans whose work already landed on trunk. +/// Delete the worktree and local branch for orphans whose work already landed on trunk. /// /// Returns the branches it cleaned. Anything that is not provably merged is left untouched and -/// reported, because an orphan with unmerged commits is lost work, not litter. +/// reported, because an orphan with unmerged commits is lost work, not litter. The remote branch +/// is deliberately left alone: sync's tracked cleanup does not delete remote refs either, and the +/// evidence here is about the *local* tip, which says nothing about commits pushed from elsewhere. fn prune_orphaned_ez_worktrees( state: &StackState, - worktree_map: &std::collections::HashMap, + main_root: &str, + current_dir: &str, + original_root: &str, fetch_remote: &str, force: bool, + shell_cd_path: &mut Option, ) -> Vec { - let orphans = orphaned_ez_worktrees(state, worktree_map); + // Read the worktree list from git rather than reusing the map captured before the cleanup + // pass. Branches cleaned above are out of `state.branches` and have had their worktrees + // removed, so a stale map entry would match every rule below and be re-reported as an orphan. + let worktree_map: std::collections::HashMap = git::worktree_list() + .unwrap_or_default() + .into_iter() + .filter(|wt| wt.path != main_root) + .filter_map(|wt| wt.branch.map(|branch| (branch, wt.path))) + .collect(); + + let orphans: Vec<(String, String)> = orphaned_ez_worktrees(state, main_root, &worktree_map) + .into_iter() + .filter(|(branch, _)| git::branch_exists(branch)) + .collect(); if orphans.is_empty() { return Vec::new(); } @@ -110,12 +132,23 @@ fn prune_orphaned_ez_worktrees( let mut cleaned = Vec::new(); for (branch, path) in &orphans { let pr_info = pr_statuses.get(branch.as_str()); - // A PR is authoritative when there is one; fall back to git for orphans that never had - // one. A closed-but-unmerged PR is not merged — that branch keeps its work. - let merged = match pr_info { - Some(pr) => pr.merged, - None => git::is_ancestor(branch, &state.trunk), + + // Git has to agree before anything is deleted. An orphan has no recorded PR number, so + // the only way to find its PR is `headRefName` — which returns the most recent PR that + // ever used that branch *name*. After a name is recycled, or in a fork workflow where + // several contributors use the same name, that is somebody else's merged PR, and trusting + // its `merged` flag alone would delete live work. The tracked cleanup path sidesteps this + // by looking PRs up by number; here git is the corroboration instead. + // + // Tip already in trunk covers a normal merge; an empty diff against trunk covers a squash + // merge, where the commits are rewritten and ancestry no longer holds. + let tip_in_trunk = git::is_ancestor(branch, &state.trunk); + let content_in_trunk = || { + git::diff(&format!("{}...{}", state.trunk, branch), true, false) + .map(|stat| stat.trim().is_empty()) + .unwrap_or(false) }; + let merged = tip_in_trunk || (pr_info.is_some_and(|pr| pr.merged) && content_in_trunk()); if !merged { ui::warn(&format!( "`{branch}` has a worktree at `{path}` but is not tracked by ez and is not merged" @@ -133,6 +166,24 @@ fn prune_orphaned_ez_worktrees( continue; } + // Removing the worktree we are standing in would leave the process — and the rest of this + // sync, which still has restacks and a `state.save()` to do — in a deleted directory. + let is_current_worktree = + inside_worktree_path(current_dir, path) || inside_worktree_path(original_root, path); + if is_current_worktree && let Err(e) = std::env::set_current_dir(main_root) { + ui::warn(&format!( + "Could not move out of orphaned worktree `{path}` before cleanup: {e}" + )); + ui::receipt(&serde_json::json!({ + "cmd": "sync", + "branch": branch, + "action": "cleanup_skipped", + "reason": "orphaned_worktree_cwd_move_failed", + "worktree": path, + })); + continue; + } + let removed = if force { git::worktree_remove_force(path) } else { @@ -153,8 +204,11 @@ fn prune_orphaned_ez_worktrees( continue; } + if is_current_worktree { + *shell_cd_path = Some(main_root.to_string()); + } + let _ = git::delete_branch(branch, true); - let _ = git::delete_remote_branch(&state.remote, branch); ui::info(&format!( "Cleaned up orphaned worktree for `{branch}` (merged, no longer tracked)" )); @@ -793,13 +847,16 @@ fn run_sync_inner(force: bool, repair_native_stack: bool) -> Result<()> { } // Safety net for worktrees whose stack entry disappeared without them — see - // `prune_orphaned_ez_worktrees`. Runs after the tracked pass so a branch cleaned above is - // already out of `state.branches` and is not considered twice. + // `prune_orphaned_ez_worktrees`. It re-reads the worktree list itself, because the map above + // predates the cleanup that just ran. cleaned.extend(prune_orphaned_ez_worktrees( &state, - &worktree_map, + &main_root, + ¤t_dir, + &original_root, &fetch_remote, force, + &mut shell_cd_path, )); let order = state.topo_order(); @@ -894,7 +951,7 @@ mod tests { ("main".to_string(), "/repo/.worktrees/main".to_string()), ]); - let orphans = orphaned_ez_worktrees(&state, &worktrees); + let orphans = orphaned_ez_worktrees(&state, "/repo", &worktrees); assert_eq!( orphans, @@ -905,6 +962,112 @@ mod tests { ); } + #[test] + fn orphaned_ez_worktrees_anchors_ownership_to_this_repo_root() { + let state = StackState::new("main".to_string()); + + // A checkout that itself lives under a `.worktrees/` directory. A bare substring test + // would claim every sibling worktree in that directory as ez's to delete. + let main_root = "/home/dev/.worktrees/project"; + let worktrees = std::collections::HashMap::from([ + ( + "feat/ours".to_string(), + "/home/dev/.worktrees/project/.worktrees/feat-ours".to_string(), + ), + ( + "feat/theirs".to_string(), + "/home/dev/.worktrees/some-other-tool".to_string(), + ), + ]); + + let orphans = orphaned_ez_worktrees(&state, main_root, &worktrees); + + assert_eq!( + orphans, + vec![( + "feat/ours".to_string(), + "/home/dev/.worktrees/project/.worktrees/feat-ours".to_string() + )], + "only worktrees under this repo's own .worktrees/ are ez-owned" + ); + } + + #[test] + fn prune_orphaned_ez_worktrees_ignores_branches_the_tracked_pass_already_cleaned() { + let _guard = crate::test_support::take_env_lock(); + let repo = crate::test_support::init_git_repo("sync-orphan-after-tracked-cleanup"); + let _cwd = crate::test_support::CwdGuard::enter(&repo); + + // Model the state right after the tracked cleanup loop: the branch is gone from git and + // from `state.branches`, and its worktree has already been removed. Reusing the worktree + // map captured before that loop would re-report it here. + let state = StackState::new("main".to_string()); + let main_root = git::repo_root().expect("repo root"); + let mut shell_cd_path = None; + + let cleaned = prune_orphaned_ez_worktrees( + &state, + &main_root, + &main_root, + &main_root, + "origin", + false, + &mut shell_cd_path, + ); + + assert!( + cleaned.is_empty(), + "a branch already cleaned by the tracked pass must not be revisited as an orphan" + ); + } + + #[test] + fn prune_orphaned_ez_worktrees_keeps_an_orphan_git_cannot_confirm_is_merged() { + let _guard = crate::test_support::take_env_lock(); + let repo = crate::test_support::init_git_repo("sync-orphan-unmerged-kept"); + let _cwd = crate::test_support::CwdGuard::enter(&repo); + + // An untracked branch in an ez-owned worktree carrying work that is NOT on trunk. This is + // the shape a recycled branch name takes: a `headRefName` PR lookup can hand back an old + // merged PR for the same name, so git ancestry is what has to decide. + git::create_branch_at("feat/recycled", "main").expect("branch"); + git::checkout("feat/recycled").expect("checkout"); + crate::test_support::write_file(&repo, "unmerged.txt", "work\n"); + crate::test_support::run_cmd(&repo, "git", &["add", "unmerged.txt"]); + crate::test_support::run_cmd(&repo, "git", &["commit", "-m", "unmerged work"]); + git::checkout("main").expect("back to main"); + + let main_root = git::repo_root().expect("repo root"); + let worktree = format!("{main_root}/.worktrees/feat-recycled"); + git::worktree_add(&worktree, "feat/recycled").expect("worktree add"); + + let state = StackState::new("main".to_string()); + let mut shell_cd_path = None; + + let cleaned = prune_orphaned_ez_worktrees( + &state, + &main_root, + &main_root, + &main_root, + "origin", + false, + &mut shell_cd_path, + ); + + assert!( + cleaned.is_empty(), + "an orphan whose commits are not in trunk must be kept, not deleted" + ); + assert!( + git::branch_exists("feat/recycled"), + "the branch must survive" + ); + assert!( + std::path::Path::new(&worktree).exists(), + "the worktree must survive" + ); + } + #[test] fn cleanup_candidate_branches_excludes_local_unmanaged_branches() { let managed = vec![