Files
Maximilian Roos bb580ed46b fix(plugin): WorktreeRemove hook skips a path holding no worktree (#3767)
## Problem

The `WorktreeRemove` hook guards with `[ -e "$p" ]` — #3493's narrowing,
which made the hook a no-op when the recorded worktree path is gone.
#3753 reports the third state between "gone" and "live": a path that
**exists but holds no worktree**. A skeleton directory left by an
interrupted create or remove has no `.git`, is invisible to both `git
worktree list` and `wt list`, and passes the existence guard, so `wt
remove <path>` runs and resolves the path as a *branch* name:

```
✗ No branch named /…/.claude/worktrees/<name>
  ↳ To list branches, run wt list --branches --remotes
# exit 1
```

An out-of-tree skeleton gives `fatal: not a git repository` instead.
Claude Code reads a nonzero `WorktreeRemove` as a failed removal and
keeps the session row, so the finished background session can never be
deleted with Ctrl+X — the same end symptom as #3488, but permanent:
prune ignores a directory that was never a worktree, so nothing heals
it.

## Solution

Test for git's marker rather than for the directory:

```diff
- [ -e "$p" ] || exit 0
+ [ -e "$p/.git" ] || exit 0
```

Every linked worktree carries a `.git` file, and a dirty, locked, or
unmerged one still does, so genuine failures keep surfacing loudly — the
#2939 spirit the #3493 guard was written to preserve. Leniency stays
scoped to "no worktree lives here" rather than becoming a blanket
success, and the change stays inside the single-quoted `bash -c` body,
so outer login-shell (fish/zsh/bash) parsing is untouched.

`-e` rather than `-f` is deliberate: the *main* worktree's `.git` is a
directory, so `-e` keeps `✗ The main worktree cannot be removed` loud
where `-f` would silently no-op it.

This composes with #3754 rather than overlapping it: the guard now runs
before `-C "$p"`, so `-C` only ever resolves a path that really is a
worktree.

One state changes beyond the reported one, in the same direction: a
registered worktree whose `.git` file was deleted by hand already failed
the hook (`fatal: not a git repository`), and now exits 0, leaving a
prunable registration for `git worktree prune`. Nothing `wt remove`
could previously remove is skipped.

## Testing

`test_worktree_remove_hook_skips_path_holding_no_worktree` runs the real
command out of `hooks.json` under `bash`, feeding it the recorded path
on stdin exactly as Claude Code does. It pins three directions, so
neither a blanket `exit 0` nor a swallowed `wt remove` failure can
satisfy it: a skeleton is a no-op and is left on disk (both in-tree and
out-of-tree), a dirty worktree still fails with `uncommitted changes`
and stays put, and that same worktree once clean is still removed.

<details><summary>Mutation evidence and hand-verified states</summary>

Each mutation was applied to `hooks.json` and the test re-run:

| mutation | result |
|---|---|
| `[ -e "$p" ]` (the pre-fix guard) | fails — `hook must be a no-op for
a path holding no worktree` |
| whole body replaced with `exit 0` | fails — `hook must still refuse a
dirty worktree, and for that reason` |
| `wt remove … \|\| exit 0` (failure swallowed) | fails — same assertion
|

Hand-verified against the built binary via `WORKTRUNK_BIN`, firing the
hook as Claude Code does:

| state | before | after |
|---|---|---|
| skeleton dir, in-tree | exit 1, `No branch named …` | exit 0,
directory untouched |
| skeleton dir, out-of-tree | exit 1, `fatal: not a git repository` |
exit 0, directory untouched |
| recorded path gone | exit 0 | exit 0 |
| clean worktree | removed | removed |
| dirty worktree | exit 1, `has uncommitted changes` | unchanged |
| unmerged branch | worktree removed, branch retained + `-D` hint |
unchanged |
| main worktree | exit 1, `The main worktree cannot be removed` |
unchanged |
| registered worktree, `.git` deleted by hand | exit 1, `fatal: not a
git repository` | exit 0, prunable entry left |

The test is gated on `all(unix, feature = "shell-integration-tests")`,
which the required `test` jobs and the coverage run both enable; the
hook parses its stdin with `jq`.

**Not verified:** Claude Code's own session-row teardown — CI can't
drive the agent UI. The evidence here is the hook's exit status, which
is what Claude Code branches on per #3488/#3493.

</details>

The full pre-merge gate passes locally (4571 tests, clippy, doctests,
docs sync, no pending snapshots).

## Relation to #3755

Supersedes worktrunk-bot's #3755, whose one-line hook edit is
byte-identical to this one. The difference is test coverage: #3755's
test passes against a hook with `|| exit 0` appended to `wt remove`,
which would silently discard the dirty-worktree refusal — verified by
running that test file against the mutation. This one also covers the
out-of-tree skeleton. #3755's `flake.nix` addition of `jq` is left out:
the devShell already omits nushell so it can't run the shell-integration
suite regardless, and the nix test derivation runs default features
only, where this test isn't compiled.

Closes #3753. Thanks to @judewang for the report, the reproduction, and
the fix direction — including the note that `git -C "$p" rev-parse
--is-inside-work-tree` is not a usable test, since discovery walks up to
the parent for a nested skeleton.

> _This was written by Claude Code on behalf of max-sixty_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:05:09 -07:00
..