Skip to content
Open
4 changes: 3 additions & 1 deletion docs/content/remove.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 the branch has no worktree from then on and `wt remove <branch>` deletes the ref alone. The directory stays where it is, and the removal names it and the `wt remove <path>` that clears it.

## Force flags

Worktrunk has two force flags for different situations:
Expand Down Expand Up @@ -98,7 +100,7 @@ Reaping runs before the worktree directory is touched, so it is independent of f

## JSON output

`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal.
`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal, plus `detached_worktree` — the directory left at that branch's path with a detached HEAD, which the branch no longer names and this removal therefore leaves alone.

`branch_outcome` names what happened to the branch, so a caller can tell a deletion the removal declined from one it was never asked to make:

Expand Down
4 changes: 3 additions & 1 deletion plugins/worktrunk/skills/worktrunk/reference/remove.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 the branch has no worktree from then on and `wt remove <branch>` deletes the ref alone. The directory stays where it is, and the removal names it and the `wt remove <path>` that clears it.

## Force flags

Worktrunk has two force flags for different situations:
Expand Down Expand Up @@ -100,7 +102,7 @@ Reaping runs before the worktree directory is touched, so it is independent of f

## JSON output

`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal.
`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal, plus `detached_worktree` — the directory left at that branch's path with a detached HEAD, which the branch no longer names and this removal therefore leaves alone.

`branch_outcome` names what happened to the branch, so a caller can tell a deletion the removal declined from one it was never asked to make:

Expand Down
4 changes: 3 additions & 1 deletion skills/worktrunk/reference/remove.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 the branch has no worktree from then on and `wt remove <branch>` deletes the ref alone. The directory stays where it is, and the removal names it and the `wt remove <path>` that clears it.

## Force flags

Worktrunk has two force flags for different situations:
Expand Down Expand Up @@ -1331,7 +1333,7 @@ Reaping runs before the worktree directory is touched, so it is independent of f

## JSON output

`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal.
`--format=json` prints one object per removal to stdout: `{kind, branch, path, branch_outcome, branch_checked_out_at}` for a worktree, with `pruned` in place of `path` for a branch-only removal, plus `detached_worktree` — the directory left at that branch's path with a detached HEAD, which the branch no longer names and this removal therefore leaves alone.

`branch_outcome` names what happened to the branch, so a caller can tell a deletion the removal declined from one it was never asked to make:

Expand Down
6 changes: 6 additions & 0 deletions src/commands/picker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2874,6 +2874,7 @@ pub mod tests {
target_branch: None,
integration_reason: None,
branch_checked_out_at: None,
detached_worktree: None,
};
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();

Expand Down Expand Up @@ -2901,6 +2902,7 @@ pub mod tests {
target_branch: None,
integration_reason: None,
branch_checked_out_at: None,
detached_worktree: None,
};
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();

Expand Down Expand Up @@ -3820,6 +3822,7 @@ pub mod tests {
target_branch: None,
integration_reason: None,
branch_checked_out_at: None,
detached_worktree: None,
};
assert_eq!(
super::removal_failure_subject(&branch_only),
Expand Down Expand Up @@ -4277,6 +4280,7 @@ pub mod tests {
target_branch: None,
integration_reason: None,
branch_checked_out_at: None,
detached_worktree: None,
};
assert!(super::removal_target_still_present(&repo, &present_branch));

Expand All @@ -4287,6 +4291,7 @@ pub mod tests {
target_branch: None,
integration_reason: None,
branch_checked_out_at: None,
detached_worktree: None,
};
assert!(!super::removal_target_still_present(&repo, &gone_branch));
}
Expand All @@ -4308,6 +4313,7 @@ pub mod tests {
target_branch: None,
integration_reason: integration,
branch_checked_out_at: None,
detached_worktree: None,
}
};

Expand Down
94 changes: 86 additions & 8 deletions src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,13 +58,71 @@ impl RemovePlans {
}
}

/// The detached worktree sitting where `branch`'s worktree would go, if any —
/// the one thing a branch-only removal of `branch` can't otherwise mention.
///
/// Detaching a worktree's HEAD severs the only link git records between it and
/// the branch, so `branch` genuinely has no worktree and deleting the ref alone
/// is the right operation. But the directory is still on disk, and `No worktree
/// found for branch <name>` reads as though it isn't. Once the link is gone the
/// `worktree-path` template is all that still connects the two, which is a
/// weaker association than the rest of the module draws — the template records
/// where a worktree *would* go, not that this one belongs to that branch, and
/// [`is_worktree_at_expected_path`] returns false for a detached worktree while
/// [`worktree_display_name`] renders one as `dir_name (detached)` without
/// consulting the template at all. That weakness is why this only ever adds a
/// line: a mention that occasionally points at a coincidence costs nothing,
/// where a refusal built on the same inference would block work.
///
/// Three cases go unmatched, each because naming them would mislead:
///
/// - a worktree checked out on some *other* branch — that branch names it, and
/// it has nothing to do with this removal;
/// - the main worktree, which `wt remove <path>` refuses with nothing to do
/// about it, so the hint would be a dead end. What decides this is whether
/// the hint leads anywhere, not whether the command succeeds: a *locked*
/// worktree refuses too and is named all the same, because that refusal
/// carries the `git worktree unlock` that clears the way. A bare repo has no
/// main worktree, so its default-branch checkout is named like any other;
/// - a prunable entry, whose directory is already gone — `wt step prune`'s to
/// sweep, and nothing to point a user at.
///
/// A template that won't expand yields no expected path and so no match. The
/// branch is known to exist by the time this runs: `prepare_worktree_removal`
/// reports a typo, a deleted branch, or a remote-only name as an error, so a
/// plan to annotate means the lookup already succeeded.
///
/// [`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> {
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)
// A `git_dir` lookup that fails says nothing either way, so
// treat it as linked and name the worktree — an extra line is
// the worst a wrong answer here can cost.
&& 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,
/// another worktree, or branch-only, and prepares the removal plan.
/// Errors are collected (not fatal) to support partial success.
fn validate_remove_targets(
repo: &Repository,
config: &UserConfig,
branches: Vec<String>,
keep_branch: bool,
force_delete: bool,
Expand Down Expand Up @@ -150,14 +208,32 @@ fn validate_remove_targets(
// Bucket the validated result, not the pre-validation resolution:
// a worktree whose directory disappeared degrades to BranchOnly
// during preparation and must run with the other branch-only plans.
Ok(result) => match &result {
RemovalPlan::Worktree {
changed_directory: true,
Ok(mut result) => {
// A branch whose worktree has since been detached still has no
// worktree — detaching severed the link — so the branch-only
// removal is right. What it can't see is the directory still
// sitting at that branch's templated path, which the output
// would otherwise never mention. Annotate here, where the
// user's typed branch name and the config are both in hand.
if let RemovalPlan::BranchOnly {
branch_name,
detached_worktree,
..
} => plans.current = Some(result),
RemovalPlan::Worktree { .. } => plans.others.push(result),
RemovalPlan::BranchOnly { .. } => plans.branch_only.push(result),
},
} = &mut result
&& let Some(wts) = worktrees
{
*detached_worktree = detached_worktree_for(repo, config, branch_name, wts)
.map(Path::to_path_buf);
}
match &result {
RemovalPlan::Worktree {
changed_directory: true,
..
} => plans.current = Some(result),
RemovalPlan::Worktree { .. } => plans.others.push(result),
RemovalPlan::BranchOnly { .. } => plans.branch_only.push(result),
}
}
Err(e) => plans.record_error(e),
}
}
Expand Down Expand Up @@ -386,6 +462,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,
Expand Down Expand Up @@ -493,6 +570,7 @@ mod tests {

let plans = validate_remove_targets(
&repo,
&UserConfig::default(),
vec!["missing-worktree".to_string(), "branch-only".to_string()],
false,
false,
Expand Down
25 changes: 18 additions & 7 deletions src/commands/repository_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 <bold>{branch}</> gained a worktree @ <bold>{path}</> since it was selected; to remove that worktree, run <bold>{}</>",
suggest_command("remove", &[&path], &[])
"Branch <bold>{branch}</> gained a worktree @ <bold>{path}</> since it was selected; to remove that worktree, run <bold>wt remove {path}</>"
));
}
// Check the branch exists locally, so a typo or a remote-only
Expand Down Expand Up @@ -323,6 +325,7 @@ impl RepositoryCliExt for Repository {
target_branch: None,
integration_reason: None,
branch_checked_out_at: Some(shared),
detached_worktree: None,
});
}
let default_branch = self.default_branch();
Expand All @@ -341,6 +344,7 @@ impl RepositoryCliExt for Repository {
target_branch,
integration_reason,
branch_checked_out_at: None,
detached_worktree: None,
});
}
Resolved::Worktree {
Expand Down Expand Up @@ -584,10 +588,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,
Expand Down
Loading
Loading