mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
bb580ed46b
## 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>