diff --git a/docs/content/remove.md b/docs/content/remove.md index 6b14753f4..e09fcb276 100644 --- a/docs/content/remove.md +++ b/docs/content/remove.md @@ -56,6 +56,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/plugins/worktrunk/skills/worktrunk/reference/remove.md b/plugins/worktrunk/skills/worktrunk/reference/remove.md index 0eee006fd..1ea9738f2 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/remove.md +++ b/plugins/worktrunk/skills/worktrunk/reference/remove.md @@ -55,6 +55,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/skills/worktrunk/reference/remove.md b/skills/worktrunk/reference/remove.md index 0eee006fd..1ea9738f2 100644 --- a/skills/worktrunk/reference/remove.md +++ b/skills/worktrunk/reference/remove.md @@ -55,6 +55,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4e91f75c..7bbf6f4b9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1286,6 +1286,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/src/commands/remove.rs b/src/commands/remove.rs index 2d1e7545f..f4d013276 100644 --- a/src/commands/remove.rs +++ b/src/commands/remove.rs @@ -16,7 +16,7 @@ use crate::output::{BackgroundFallbackMode, RemovalExecution, handle_remove_outp use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder}; use super::hooks::HookAnnouncer; use super::repository_ext::RepositoryCliExt; -use super::worktree::{BranchFate, RemovalPlan}; +use super::worktree::{BranchFate, RemovalPlan, compute_worktree_path}; use super::{RemoveTarget, flag_pair}; /// The execution mode `--foreground` selects; the background default falls @@ -58,6 +58,68 @@ impl RemovePlans { } } +/// The removable detached worktree sitting where `branch`'s worktree belongs, +/// if any. +/// +/// Detaching a worktree's HEAD severs the only link git records between it and +/// the branch, so once that happens the `worktree-path` template is all that +/// still connects the two. That is a stronger association than the rest of the +/// module draws — [`is_worktree_at_expected_path`] returns false for a detached +/// worktree, and [`worktree_display_name`] renders one as `dir_name (detached)` +/// without consulting the template — and it is intentional here: the template +/// match is what keeps the removal from stranding it. Three cases are +/// deliberately not matched, because refusing on them would name a removal that +/// can't happen or protect nothing: +/// +/// - a worktree checked out on some *other* branch — that branch names it, so +/// removing this one strands nothing; +/// - the main worktree, which `wt remove ` refuses — the hint would name +/// a command that can't run, and the branch falls through to the accurate +/// [`CannotRemoveDefaultBranch`](worktrunk::git::GitError::CannotRemoveDefaultBranch). +/// A bare repo has no main worktree, so its default-branch checkout is +/// matched like any other and the hint works; +/// - a prunable entry, whose directory is already gone — stale metadata for +/// `wt step prune` to sweep, not a worktree left on disk. +/// +/// A template that won't expand yields no expected path and so no match: the +/// guard can't assert what it can't compute, and refusing every branch-only +/// removal on a broken template would cost more than the case it guards. +/// +/// A name that is no local branch at all is rejected before any of that, so the +/// refusal never asserts a branch that isn't there. +/// +/// [`is_worktree_at_expected_path`]: super::worktree::is_worktree_at_expected_path +/// [`worktree_display_name`]: super::worktree::worktree_display_name +fn detached_worktree_for<'a>( + repo: &Repository, + config: &UserConfig, + branch: &str, + worktrees: &'a [worktrunk::git::WorktreeInfo], +) -> Option<&'a Path> { + // Downstream, `prepare_worktree_removal` is what reports a typo, a deleted + // branch, or a remote-only name; a guard that fires ahead of it would + // assert a branch that doesn't exist. `exists_locally` reports a failed + // lookup as `false` rather than an error, so this can't fail closed — but + // the same call downstream then refuses the removal, so a guard skipped + // that way still never deletes a ref. + if !repo.branch(branch).exists_locally().unwrap_or(true) { + return None; + } + let expected = compute_worktree_path(repo, branch, config).ok()?; + worktrees + .iter() + .find(|wt| { + wt.branch.is_none() + && !wt.is_prunable() + && worktrunk::path::paths_match(&wt.path, &expected) + // Fail closed: a `git_dir` lookup that fails says nothing about + // whether the worktree is linked, and skipping the guard there + // deletes the ref and strands the worktree. + && repo.worktree_at(&wt.path).is_linked().unwrap_or(true) + }) + .map(|wt| wt.path.as_path()) +} + /// Validate all removal targets, returning categorized plans. /// /// Resolves each branch name, determines whether it's the current worktree, @@ -65,6 +127,7 @@ impl RemovePlans { /// Errors are collected (not fatal) to support partial success. fn validate_remove_targets( repo: &Repository, + config: &UserConfig, branches: Vec, keep_branch: bool, force_delete: bool, @@ -126,7 +189,45 @@ fn validate_remove_targets( // otherwise (see its shared-branch handling). RemoveTarget::WorktreePath(path_canonical) } - ResolvedWorktree::BranchOnly { branch } => RemoveTarget::BranchOnly(branch), + ResolvedWorktree::BranchOnly { branch } => { + // A detached worktree is invisible to the branch-first lookup, + // so a branch whose worktree has since been detached resolves + // here and would have its ref deleted with the worktree left + // registered. Refuse instead, and name the path — the only + // spelling that still reaches it. + // + // The guard belongs to this command rather than to + // `prepare_worktree_removal`, which every producer of a + // `BranchOnly` target shares: `wt step prune` plans its whole + // sweep from one worktree-list snapshot, so a detached + // worktree it is about to remove as its own candidate is still + // registered when the branch's plan is built. Refusing there + // would leave prune unable to clean up either half. + // + // Under `Keep` (`--no-delete-branch`, or + // `[remove] delete-branch = false`) this arm deletes nothing, + // so there is no ref to strand the worktree behind — the guard + // would turn a no-op into a failure. `SafeDelete` on an + // unintegrated branch retains its ref too, but only the + // deletion attempt downstream knows that + // (`Repository::integration_reason`), so those refuse here + // rather than exiting 0 — the conservative direction, since + // what the refusal names is still on disk. + if let Some(detached) = worktrees + .filter(|_| !deletion_mode.should_keep()) + .and_then(|wts| detached_worktree_for(repo, config, &branch, wts)) + { + plans.record_error( + GitError::DetachedWorktreeForBranch { + branch, + path: detached.to_path_buf(), + } + .into(), + ); + continue; + } + RemoveTarget::BranchOnly(branch) + } // Resolution tried the argument as a branch and as a worktree path // and matched neither, so a directory sitting there is a leftover // skeleton rather than anything wt can remove. Only a typed @@ -386,6 +487,7 @@ pub fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> // Multi-worktree removal: validate ALL first, then approve, then execute let plans = validate_remove_targets( &repo, + &config, branches, !delete_branch, args.force_delete, @@ -493,6 +595,7 @@ mod tests { let plans = validate_remove_targets( &repo, + &UserConfig::default(), vec!["missing-worktree".to_string(), "branch-only".to_string()], false, false, diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 1b308fe58..4292261a9 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -8,7 +8,7 @@ use worktrunk::git::{ parse_porcelain_z, parse_untracked_files, }; use worktrunk::path::format_path_for_display; -use worktrunk::styling::{eprintln, format_with_gutter, suggest_command, warning_message}; +use worktrunk::styling::{eprintln, format_with_gutter, warning_message}; /// Target for worktree removal. #[derive(Debug)] @@ -167,10 +167,12 @@ impl RepositoryCliExt for Repository { .iter() .find(|wt| wt.branch.as_deref() == Some(branch.as_str())) { + // `path` is already shell-ready, so the suggested command + // interpolates it rather than passing it through + // `suggest_command`, which would escape it a second time. let path = format_path_for_display(&wt.path); bail!(cformat!( - "Branch {branch} gained a worktree @ {path} since it was selected; to remove that worktree, run {}", - suggest_command("remove", &[&path], &[]) + "Branch {branch} gained a worktree @ {path} since it was selected; to remove that worktree, run wt remove {path}" )); } // Check the branch exists locally, so a typo or a remote-only @@ -578,10 +580,17 @@ pub(crate) fn compute_integration_reason( /// with an unresolvable `HEAD`, so every removal that could delete a branch /// asks this first. /// -/// Only a live directory counts. A sibling entry whose directory is already -/// gone is stale metadata awaiting `git worktree prune`, not a checkout with -/// anything to lose — retaining a branch for it would strand the branch and -/// point the user at a directory that isn't there. +/// Only a live directory counts: a sibling entry whose directory is gone is +/// stale metadata awaiting `git worktree prune`, not a checkout with anything +/// to lose, and retaining a branch for it would strand the branch and point the +/// user at a directory that isn't there. +/// +/// `exists()` is the test, not [`Repository::worktree_is_unusable`], which the +/// rest of the removal path uses. The two disagree on a directory that is +/// present but no longer holds its worktree, and the disagreement is +/// asymmetric: calling a dead sibling live retains a branch nobody needed, +/// while calling a live one dead deletes a branch a checkout still resolves. +/// This answer only ever gates a deletion, so it takes the conservative test. pub(crate) fn live_sibling_checkout<'a>( worktrees: &'a [WorktreeInfo], branch: &str, diff --git a/src/git/error.rs b/src/git/error.rs index 2acb68b85..9db0f7051 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -588,6 +588,28 @@ pub enum GitError { WorktreeNotFound { branch: String, }, + /// A branch with no worktree, whose worktree's place is taken by a + /// registered worktree with a detached HEAD. + /// + /// Detaching severs the only link git records between a worktree and a + /// branch, so the branch-first lookup reports no worktree and an operation + /// addressed by branch degrades to acting on the ref alone. For removal + /// that means deleting the ref, leaving the worktree registered, and + /// reporting success. Refusing is the honest answer, and the path is the + /// only spelling that still reaches the worktree. + /// + /// Distinct from [`GitError::WorktreeNotFound`], where the branch has no + /// checkout anywhere and creating one is the right suggestion. + /// + /// [`GitError::WorktreePathOccupied`] reports the same physical state to + /// `wt switch`, which wants the worktree back on the branch and says so. + /// Removal wants it gone, so the two carry different hints and split on the + /// occupied-by-another-branch case: that one blocks a switch, and leaves a + /// removal nothing to strand. + DetachedWorktreeForBranch { + branch: String, + path: PathBuf, + }, /// A worktree selector matched neither a branch nor a worktree path. /// /// Distinct from [`GitError::WorktreeNotFound`], which means the branch @@ -883,6 +905,13 @@ impl GitError { cformat!("Branch {branch} has no worktree") } + GitError::DetachedWorktreeForBranch { branch, path } => { + let path_display = format_path_for_display(path); + cformat!( + "Branch {branch} has no worktree; the one @ {path_display} is detached" + ) + } + GitError::WorktreeSelectorNotFound { selector } => { cformat!("No branch or worktree named {selector}") } @@ -1441,6 +1470,22 @@ impl GitError { ) } + GitError::DetachedWorktreeForBranch { path, .. } => { + let title = self.title(); + // `format_path_for_display` already returns a shell-ready + // token, so the command is built by interpolation — routing it + // through `suggest_command` would escape it a second time. + let display_path = format_path_for_display(path); + write!( + f, + "{}\n{}", + error_message(&title), + hint_message(cformat!( + "To remove the detached worktree, run wt remove {display_path}" + )) + ) + } + GitError::WorktreeSelectorNotFound { .. } => { let title = self.title(); write!( diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index 8f87c49bc..b563752cd 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -13,9 +13,7 @@ use super::{ normalize_selector, resolve_input_path, }; use crate::path::{format_path_for_display, paths_match}; -use crate::styling::{ - eprintln, format_with_gutter, hint_message, suggest_command, warning_message, -}; +use crate::styling::{eprintln, format_with_gutter, hint_message, warning_message}; impl Repository { /// List all worktrees for this repository. @@ -757,7 +755,8 @@ fn warn_duplicate_checkout(branch: &str, paths: &[PathBuf]) { // removes exactly the worktree named and retains the branch the others // still hold, so it's safe to suggest for a duplicate. for extra in &paths[1..] { - let cmd = suggest_command("remove", &[&format_path_for_display(extra)], &[]); + // Already shell-ready; `suggest_command` would escape it again. + let cmd = format!("wt remove {}", format_path_for_display(extra)); eprintln!( "{}", hint_message(cformat!("To drop a duplicate, run {cmd}")) diff --git a/src/path.rs b/src/path.rs index 4ded02115..f18f436d2 100644 --- a/src/path.rs +++ b/src/path.rs @@ -103,6 +103,14 @@ fn needs_shell_escaping(s: &str) -> bool { /// Uses POSIX shell escaping since all our hints target POSIX-compatible shells /// (bash, zsh, fish, and Git Bash on Windows). /// +/// The result is already shell-ready, so a hint embeds it by interpolation +/// (`rm -rf {path}`). Passing it to [`suggest_command`] escapes it a second +/// time: `~/repo` becomes `'~/repo'`, which the shell no longer tilde-expands, +/// and `'/tmp/my repo'` becomes a string carrying literal quote characters that +/// resolves to nothing. +/// +/// [`suggest_command`]: crate::styling::suggest_command +/// /// # Examples /// - `/Users/alex/repo` → `~/repo` (no escaping needed) /// - `/Users/alex/my repo` → `'/Users/alex/my repo'` (needs quoting, use original) diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index 6e13a0c62..e1464fb0f 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -292,6 +292,161 @@ fn test_remove_locked_worktree_directory_missing(mut repo: TestRepo) { ); } +/// Regression test for #3769: `wt remove ` on a worktree whose HEAD has +/// since been detached deleted the branch, left the worktree registered, and +/// exited 0. Detaching severs the only link git records between the two, so the +/// branch-first lookup found no worktree and the removal degraded to +/// branch-only — half the operation, and the silent half. +/// +/// Refusing is defensible because the path still reaches the worktree, which +/// `test_remove_detached_worktree_by_path` pins. +#[rstest] +fn test_remove_branch_whose_worktree_was_detached(mut repo: TestRepo) { + let _worktree_path = repo.add_worktree("detached-later"); + repo.detach_head_in_worktree("detached-later"); + + let output = repo + .wt_command() + .args(["remove", "detached-later", "--foreground", "--yes"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "wt remove should refuse a branch whose worktree has been detached.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // The branch must survive — deleting it is the half that used to run. + let branch_exists = repo + .git_command() + .args(["branch", "--list", "detached-later"]) + .run() + .unwrap(); + assert!( + !String::from_utf8_lossy(&branch_exists.stdout) + .trim() + .is_empty(), + "Branch should NOT be deleted while a detached worktree occupies its path", + ); + + // ...and so must the worktree it would otherwise have stranded. + let list_after = repo + .git_command() + .args(["worktree", "list", "--porcelain"]) + .run() + .unwrap(); + assert!( + String::from_utf8_lossy(&list_after.stdout).contains("detached-later"), + "Detached worktree should still be registered", + ); +} + +/// The refusal names the worktree and the path-based removal that reaches it. +#[rstest] +fn test_remove_branch_with_detached_worktree_message(mut repo: TestRepo) { + repo.add_worktree("feature-detached-strand"); + repo.detach_head_in_worktree("feature-detached-strand"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-strand"], + None + )); +} + +/// The default branch's expected path is the main worktree, so a detached main +/// worktree would match the #3769 guard — but `wt remove ` refuses +/// too, so pointing at it would be a dead end. The default-branch refusal, which +/// is the accurate answer, must still be what surfaces. +#[rstest] +fn test_remove_default_branch_with_detached_main_worktree(repo: TestRepo) { + repo.run_git(&["checkout", "--detach", "HEAD"]); + + assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["main"], None)); +} + +/// The #3769 guard runs ahead of the branch-existence check in +/// `prepare_worktree_removal`, so it must not claim a branch that isn't there: +/// a name whose templated path holds a detached worktree still reports the +/// missing branch once the ref is gone. +#[rstest] +fn test_remove_missing_branch_with_detached_worktree_at_its_path(mut repo: TestRepo) { + repo.add_worktree("feature-detached-orphan"); + repo.detach_head_in_worktree("feature-detached-orphan"); + repo.run_git(&["branch", "-D", "feature-detached-orphan"]); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-orphan"], + None + )); +} + +/// Under `--no-delete-branch` the branch-only removal deletes nothing, so there +/// is no ref whose deletion could strand the detached worktree — the #3769 +/// guard must not turn that no-op into a failure. +#[rstest] +fn test_remove_branch_with_detached_worktree_keeping_branch(mut repo: TestRepo) { + repo.add_worktree("feature-detached-keep"); + repo.detach_head_in_worktree("feature-detached-keep"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["--no-delete-branch", "feature-detached-keep"], + None + )); +} + +/// An unintegrated branch keeps its ref under `SafeDelete` too, but only the +/// deletion attempt downstream knows that — so unlike the `--no-delete-branch` +/// case above, a detached worktree refuses here rather than exiting 0 with the +/// branch retained. The refusal names a worktree that is still on disk, so this +/// pins the conservative direction rather than an accident. +#[rstest] +fn test_remove_unmerged_branch_with_detached_worktree_refuses(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-detached-unmerged"); + std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap(); + repo.git_command() + .args(["add", "feature.txt"]) + .current_dir(&worktree_path) + .run() + .unwrap(); + repo.git_command() + .args(["commit", "-m", "Add feature"]) + .current_dir(&worktree_path) + .run() + .unwrap(); + repo.detach_head_in_worktree("feature-detached-unmerged"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-unmerged"], + None + )); +} + +/// A detached worktree whose directory is already gone is stale metadata, not a +/// worktree the #3769 guard protects — the branch-only removal proceeds rather +/// than refusing and naming a path that isn't on disk. +#[rstest] +fn test_remove_branch_with_prunable_detached_worktree(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-detached-gone"); + repo.detach_head_in_worktree("feature-detached-gone"); + std::fs::remove_dir_all(&worktree_path).unwrap(); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["-D", "feature-detached-gone"], + None + )); +} + #[rstest] fn test_remove_by_name_from_main(mut repo: TestRepo) { // Create a worktree @@ -2622,8 +2777,10 @@ fn test_remove_detached_worktree_in_multi(mut repo: TestRepo) { // Detach HEAD in feature-b repo.detach_head_in_worktree("feature-b"); - // From main, try to multi-remove both - // feature-a should succeed, feature-b should fail (detached HEAD) + // From main, try to multi-remove both. feature-a is removed; feature-b is + // refused, because detaching dropped it out of the branch-first lookup and + // removing it by branch would delete the ref and strand the worktree + // (#3769). Partial success is the point: one refusal doesn't stop the rest. assert_cmd_snapshot!(make_snapshot_cmd( &repo, "remove", diff --git a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap index 0806c5764..7959609eb 100644 --- a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap @@ -35,6 +35,7 @@ info: WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" WORKTRUNK_TEST_ZSH_INSTALLED: "0" --- @@ -147,6 +148,8 @@ Branches matching these conditions and with empty working trees are dimmed in [ Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via git worktree add --force) fails a different test: deleting the ref would leave that worktree unable to resolve HEAD, which is why git branch -d refuses the same delete. Such a branch is retained whatever -D asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a wt remove  that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under --no-delete-branch (or delete-branch = false) no ref is deleted, so nothing is stranded and the removal stays a no-op. + Force flags Worktrunk has two force flags for different situations: diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap new file mode 100644 index 000000000..57b01888e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - "--no-delete-branch" + - feature-detached-keep + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ No worktree found for branch feature-detached-keep diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap new file mode 100644 index 000000000..9160fd423 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-strand + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Branch feature-detached-strand has no worktree; the one @ _REPO_.feature-detached-strand is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-detached-strand diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap new file mode 100644 index 000000000..de6befd2c --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - "-D" + - feature-detached-gone + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ No worktree found for branch feature-detached-gone +✓ Removed branch feature-detached-gone (--force-delete) diff --git a/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap b/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap new file mode 100644 index 000000000..2d6a87c45 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - main + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot remove the default branch main +↳ To force-delete, run wt remove -D main diff --git a/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap b/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap index 86d45938b..d6b367aaf 100644 --- a/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap +++ b/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap @@ -8,12 +8,23 @@ info: - feature-b env: APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" GIT_TERMINAL_PROMPT: "0" HOME: "[TEST_HOME]" LANG: C @@ -36,19 +47,20 @@ info: WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- -success: true -exit_code: 0 +success: false +exit_code: 1 ----- stdout ----- ----- stderr ----- +✗ Branch feature-b has no worktree; the one @ _REPO_.feature-b is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-b ◎ Removing feature-a worktree in background ↳ Branch unmerged; to delete, run wt remove -D feature-a -○ No worktree found for branch feature-b -○ Branch feature-b retained; has unmerged changes -↳ To delete the unmerged branch, run wt remove -D feature-b diff --git a/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap b/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap new file mode 100644 index 000000000..d51d91b34 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-orphan + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ No branch named feature-detached-orphan +↳ To list branches, run wt list --branches --remotes diff --git a/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap b/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap new file mode 100644 index 000000000..6a75bc3c8 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-unmerged + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Branch feature-detached-unmerged has no worktree; the one @ _REPO_.feature-detached-unmerged is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-detached-unmerged