Skip to content

fix(plugin): resolve WorktreeRemove against the worktree path, not the project dir - #3754

Merged
max-sixty merged 2 commits into
max-sixty:mainfrom
judewang:fix-worktree-remove-anchor-at-path
Aug 6, 2026
Merged

max-sixty merged 2 commits into
max-sixty:mainfrom
judewang:fix-worktree-remove-anchor-at-path

Conversation

@judewang

@judewang judewang commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #3489. That PR anchored the WorktreeRemove hook at CLAUDE_PROJECT_DIR, which fixes the case where the variable names the repository. It cannot help when the anchor itself is not inside a repository — which is the normal situation for the claude agents view, and the case I still hit after #3489 landed.

Why the project dir isn't a usable anchor here

The claude agents view is a single list spanning every repository you have run a background session in. It is launched from one directory, and for anyone managing several repos that directory is typically a parent that merely contains them rather than a repository itself:

claude daemon run --origin transient \
  --spawned-by {"label":"claude agents","cwd":"/…/Repositories/github.com/<org>",…}

/…/Repositories/github.com/<org> holds several clones and is not a git repository. The daemon supervisor's own cwd is $HOME, and its environment carries no CLAUDE_PROJECT_DIR at all. So both the anchor and the :-. fallback land outside any repository, and wt remove <path> dies exactly as it did before #3489:

✗ Failed to remove worktree
  fatal: not a git repository (or any of the parent directories): .git
# exit 1 → "WorktreeRemove hook failed" → session undeletable

The deletion runs in the long-lived daemon, so relaunching claude agents from inside a repository does not change the outcome. More generally: CLAUDE_PROJECT_DIR can only ever name one directory, but the agents view is inherently multi-repo — no single value is correct for every row in the list.

This is probably why it has stayed quiet: it only bites background sessions driven from the agents view, and only once the anchor directory isn't itself a repository.

Change

Resolve against the worktree path itself, using the existing global -C:

-bash -c 'p=$(jq -er .worktree_path) || exit 1; cd "${CLAUDE_PROJECT_DIR:-.}" || exit 1; [ -e "$p" ] || exit 0; bash "$CLAUDE_PLUGIN_ROOT/hooks/wt.sh" remove --foreground "$p"'
+bash -c 'p=$(jq -er .worktree_path) || exit 1; [ -e "$p" ] || exit 0; bash "$CLAUDE_PLUGIN_ROOT/hooks/wt.sh" -C "$p" remove --foreground "$p"'

$p is strictly more specific than any environment variable: a linked worktree carries a .git file pointing at <repo>/.git/worktrees/<name>, so discovery from inside it resolves the owning repository wherever the worktree lives on disk. That makes the fix layout-independent — it behaves identically for the default out-of-tree layout and for a worktree-path pointing inside the repo. The [ -e "$p" ] guard from #3493 already runs first, so -C "$p" is only reached when the directory exists.

Reproduction

cwd $HOME, CLAUDE_PROJECT_DIR unset, worktree clean and pushed. Run against both layouts — an out-of-tree worktree (default worktree-path) and one inside the repo:

hook body out-of-tree in-repo
cd "${CLAUDE_PROJECT_DIR:-.}" … remove (current) fatal: not a git repository, exit 1 fatal: not a git repository, exit 1
-C "$p" … remove (this PR) ✓ Removed … worktree & branch, exit 0 ✓ Removed … worktree & branch, exit 0

With CLAUDE_PROJECT_DIR set to the repository, both forms succeed — this PR only changes the case where it isn't.

Knock-on: which .config/wt.toml supplies the removal hooks

The anchor also selects the project config that pre-remove / post-remove are taken from — they come from the invoking worktree, so -C "$p" makes that the worktree being removed rather than the project dir. Approvals are keyed on the repo-level project identifier, so a checked-in config the session never touched resolves identically and nothing changes.

The one behavioural delta is a session that edited .config/wt.toml inside its own worktree: those commands are then unapproved, and because the hook's stdin isn't a TTY, prompt_for_batch_approval returns NotInteractive and wt remove exits nonzero instead of continuing hookless. That is fail-closed, which seems right for a command the agent itself wrote — but it does mean that particular session stays undeletable from the agents view, so this trades one instance of the symptom for a narrower one rather than removing it outright.

Tests

Initially none: an anchor regression surfaces loudly rather than silently, unlike #2939 / #3545, and #3453 / #3489 / #3493 each landed as a one-line hooks.json change with the test file untouched. On review it's a fair point that this is the fourth change to these two commands and test_plugin_layout_is_consolidated is where each prior fix was pinned, so the second commit adds an assertion there: the WorktreeRemove command must resolve via -C "$p", and must not depend on CLAUDE_PROJECT_DIR. It sits next to the existing -D and pipefail checks on worktree_remove_cmd, and fails against the pre-fix hooks.json.

The existing assertions still hold: every hook command references $CLAUDE_PLUGIN_ROOT unbraced — unchanged; the WorktreeRemove command contains no -D / --force-delete — -C is neither. test_claude_hook_commands_parse_in_all_shells checks the outer command line under fish/zsh/bash, and the edit stays inside the single-quoted bash -c body.

Alternatives considered

  • cd "$p" rather than -C "$p" — also works, but it moves the shell into the directory that is about to be deleted. -C leaves the cwd alone and matches how wt step prune -C is already used elsewhere.
  • Keeping CLAUDE_PROJECT_DIR as a fallback — adds nothing. When it names the right repository, -C "$p" resolves to that same repository; when it doesn't, it is the bug being fixed.

Out of scope

A path that exists on disk but holds no worktree (a skeleton left by an interrupted create/remove) still fails, because [ -e "$p" ] passes and wt remove then finds nothing to remove. That is orthogonal to the anchor — tracked separately in #3753.

The CLAUDE_PROJECT_DIR anchor added in max-sixty#3489 fixes the case where the
variable names the repository, but cannot help when the anchor itself is not
inside one — the normal situation for the claude agents view, which spans
every repository with a background session and is often launched from a
parent directory that merely contains repos.

Resolve against the worktree path itself via the global -C instead. A linked
worktree carries a .git file pointing at its owning repository, so discovery
from inside it works for both the out-of-tree and in-repo layouts. The
existing [ -e "$p" ] guard from max-sixty#3493 already runs first.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding this one for a human rather than approving: the diff edits the WorktreeRemove entry in plugins/worktrunk/hooks/hooks.json, and that command is shipped automation that runs wt remove unattended (worktree directory removal plus branch deletion) when Claude Code tears a session down. Repo policy is that changes reaching that surface get a maintainer's sign-off — @max-sixty, over to you. The reasoning in the description holds up on my read: $p carries a .git file pointing back at <repo>/.git/worktrees/<name>, so -C "$p" resolves the owning repository regardless of where the worktree sits, the [ -e "$p" ] guard from #3493 still runs first, and passing "$p" positionally as well as to -C is what keeps a non-worktree path a hard error — resolve_worktree falls through to BranchOnly { branch: "/abs/path" }, which can't name a real branch, rather than falling back to the enclosing worktree.

Two things worth putting on the record:

The anchor also decides which .config/wt.toml supplies the removal hooks. pre-remove / post-remove are selected from the invoking worktree's project config (approve_remove in remove.rs), and -C "$p" makes that the worktree being removed instead of the project dir. Approvals are keyed on the repo-level project identifier (remote URL, else repo root), so a checked-in config the agent never touched resolves identically and nothing changes. The delta shows up when the agent session edited .config/wt.toml in its own worktree: those commands are then unapproved, and because the hook's stdin isn't a TTY, prompt_for_batch_approval returns NotInteractive and the whole wt remove exits nonzero instead of continuing hookless. Fail-closed is the right direction for an unapproved project command, but it's a new way for the hook to fail that the description doesn't cover.

A test would be cheap here. I take the point that an anchor regression surfaces loudly rather than silently, unlike #2939 / #3545. But this is the fourth PR against these two hook commands (#3453, #3489, #3493, #3546), and test_plugin_layout_is_consolidated is already the place where each prior fix got pinned — an assertion that the WorktreeRemove command resolves against worktree_path rather than depending on CLAUDE_PROJECT_DIR would sit next to the existing -D and pipefail checks and stop the anchor from being re-litigated a fifth time. It's outside this diff, so I haven't left it inline; happy to push it as a commit on this branch if you want it.

This is the fourth change to these two hook commands, and
test_plugin_layout_is_consolidated is where each prior fix was pinned.
Assert both halves of the anchor: the WorktreeRemove command resolves via
-C "$p", and does not depend on CLAUDE_PROJECT_DIR.

The assertion fails against the pre-fix hooks.json and passes after.
@judewang

judewang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both points taken, and the second one is a real gap in my description.

On the project-config anchor. You're right, and I'd missed it: remove.rs says it outright — every pre-remove / post-remove / post-switch hook is "selected from the invoking worktree's .config/wt.toml — the worktree wt remove ran in". So -C "$p" does move that selection from the project dir to the worktree being removed. As you say, approvals are keyed on the repo-level project identifier, so a checked-in config the session never touched resolves identically; the delta is exactly the case you describe, where the session edited .config/wt.toml in its own worktree and prompt_for_batch_approval returns NotInteractive with no TTY.

Fail-closed is the right call for a command the agent itself wrote, but it's worth being explicit that it trades one undeletable-session case for a narrower one rather than removing it outright — I've added a section to the description rather than leave that implicit. The alternatives I can see are worse: --no-hooks in the hook command would drop legitimate post-remove cleanup (killing dev servers and the like), and anything --yes-shaped would silently approve commands the agent wrote itself. Happy to follow your preference if you'd rather it couldn't fail that way at all.

On the test. Fair — four PRs against these two commands is the argument, and I'd rather not have you push it. Added as a second commit: it sits next to the -D and pipefail checks on worktree_remove_cmd and pins both halves — resolves via -C "$p", and does not depend on CLAUDE_PROJECT_DIR. It fails against the pre-fix hooks.json and passes after.

Also noted your point about resolve_worktree falling through to BranchOnly { branch: "/abs/path" } — that's a sharper statement of why a non-worktree path stays a hard error than what I had in the description.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added test is the one I offered to push, and it checks out against the shipped command: it pins both halves next to the existing -D and pipefail assertions on worktree_remove_cmd, and !contains("CLAUDE_PROJECT_DIR") is scoped to that command, so the WorktreeCreate hook's cd "${CLAUDE_PROJECT_DIR:-.}" is unaffected. I also confirmed the flag placement: -C is declared global = true on Cli::directory and applied via set_base_path in apply_global_options before any repo discovery, so -C "$p" ahead of the subcommand does anchor discovery at the worktree. Nothing further from me on the diff.

Still not approving, for the reason in my earlier review rather than a new one: the diff edits the WorktreeRemove entry in plugins/worktrunk/hooks/hooks.json, which is shipped automation that runs wt remove unattended, and repo policy reserves that surface for a maintainer. Both points from that review are now closed — the description covers the project-config knock-on, and the test is in — so this is only waiting on @max-sixty, who is already on the review request.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

One thing my review just now didn't answer, since you asked directly: on whether I'd rather it couldn't fail that way at all — leave this PR as it is. --no-hooks and anything --yes-shaped are both worse, as you say. But I don't think the residual case is the hook command's to fix, and it's worth naming where it does live, because wt remove is currently stricter without a TTY than it is with one.

An explicit interactive decline continues the removal hookless — remove.rs prints Commands declined, continuing removal without hooks and proceeds with an empty plan. No TTY at all never reaches that arm: prompt_for_batch_approval returns GitError::NotInteractive, which propagates out of approve and aborts the whole removal. So a user who says "no" gets their worktree removed, and the hook that can't be asked doesn't.

CLAUDE.md already has the rule for a context that can't prompt — consult approvals read-only and run only the already-approved subset, which is what HookPlan::approve_readonly does for the picker and for wt step prune. A non-TTY wt remove taking that path would be strictly safer than the existing decline arm (which drops approved post-remove cleanup along with everything else), would still never run a command the agent wrote itself, and would leave no session undeletable — closing the narrower case this PR trades down to.

That's a change to wt remove's approval path with its own tests, and a policy call about non-interactive wt remove generally rather than about this hook, so it doesn't belong on a one-line hooks.json fix. @max-sixty, happy to open it as a follow-up if you want it; either way it doesn't block this.

@max-sixty
max-sixty merged commit 3817df0 into max-sixty:main Aug 6, 2026
38 checks passed
@max-sixty

Copy link
Copy Markdown
Owner

thanks @judewang !

max-sixty added a commit that referenced this pull request Aug 7, 2026
)

## 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>
max-sixty added a commit that referenced this pull request Aug 7, 2026
Both rework features this release already documents, so they fold into
the existing bullets rather than adding new ones: #3766 extends #3746's
broken-pipe fix to every remaining stdout surface, and #3767 narrows the
plugin removal hook's guard from #3754.

#3766 also stops `wt list` coloring a pipe, which is user-visible on its
own, so that gets its own entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.72.0

Created-by: HarmonybrewBot
Commit-by: HarmonybrewBot
Merged-by: HarmonybrewBot
Description: Created by `brew bump`

---

Created with `brew bump-formula-pr`.<details>
  <summary>release notes</summary>
  <pre>## Release Notes

### Improved

- **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.life-white.uk`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673))

- **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves)

- **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting)

- **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests)

- **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677))

- **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747))

- **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675))

### Fixed

- **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766))

- **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740))

- **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734))

- **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting)

- **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759))

- **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report)

- **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600))

- **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762))

- **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727))

- **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719))

- **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692))

- **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656))

- **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731))

- **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687))

- **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688))

### Documentation

- **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709))

- **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706))

### Internal

- **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt

See merge request: Harmonybrew/homebrew-core!16275
max-sixty added a commit that referenced this pull request Sep 28, 2026
#4304)

Adds a hidden `wt config plugins claude hook` subcommand that reads
Claude Code's hook payload from stdin and acts on its `hook_event_name`:
activity markers, the `EnterWorktree` approval, and `WorktreeCreate` /
`WorktreeRemove`. This is the first half of getting the Claude Code
plugin into Anthropic's plugin directory, whose validator blocks our
current `hooks/hooks.json` because its commands compute paths (`-C
"$CLAUDE_PROJECT_DIR"`) and carry inline programs (`bash -c '…$(jq
…)…'`). Once `hooks.json` runs this subcommand, every hook command is
the same literal line and the plugin no longer needs `jq`.

`hooks.json` itself is unchanged here. The marketplace serves the plugin
from `main`, so switching it before a release ships `hook` would break
worktree creation for everyone on v0.79. The switch is a separate draft
PR, to merge a few weeks after the release that ships this.

- `src/commands/config/plugins.rs`: `handle_claude_hook` dispatches on
the event and re-runs the same `wt` binary with the command a user would
type, so each action behaves exactly as the old hook command did. Its
doc comment carries the per-event contracts (#2939, #3545, #3754,
#3921).
- `approve-enter-worktree` stays, sharing its check with `hook`, for
plugin copies installed before `hook` existed: marketplace installs
don't update on their own.
- `tests/integration_tests/claude_hook.rs` covers the new subcommand
directly; the existing shell-integration tests still run the real
`hooks.json` commands.
- `plugin.json` gains the listing metadata the directory reads
(`license`, `homepage`, `repository`, `keywords`, `icon`, with
`icon.png` copied from `docs/public/logo.png`), and the plugin README
describes what each hook runs, which the directory's security scan
checks for.
- The direnv example in the tips docs builds its path with direnv's
`expand_path` instead of `$PWD`, which the directory scanner read as a
credential.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
tend — b48066aa Deployed Aug 6, 2026 by judewang via review #3735
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants