Repository navigation
fix(plugin): resolve WorktreeRemove against the worktree path, not the project dir - #3754
Conversation
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
left a comment
There was a problem hiding this comment.
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.
|
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: 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: 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 Also noted your point about |
worktrunk-bot
left a comment
There was a problem hiding this comment.
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.
|
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. An explicit interactive decline continues the removal hookless — 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 That's a change to |
|
thanks @judewang ! |
) ## 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>
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>
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
#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>
Follow-up to #3489. That PR anchored the
WorktreeRemovehook atCLAUDE_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 theclaude agentsview, and the case I still hit after #3489 landed.Why the project dir isn't a usable anchor here
The
claude agentsview 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:/…/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 noCLAUDE_PROJECT_DIRat all. So both the anchor and the:-.fallback land outside any repository, andwt remove <path>dies exactly as it did before #3489:The deletion runs in the long-lived daemon, so relaunching
claude agentsfrom inside a repository does not change the outcome. More generally:CLAUDE_PROJECT_DIRcan 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:$pis strictly more specific than any environment variable: a linked worktree carries a.gitfile 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 aworktree-pathpointing 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_DIRunset, worktree clean and pushed. Run against both layouts — an out-of-tree worktree (defaultworktree-path) and one inside the repo:cd "${CLAUDE_PROJECT_DIR:-.}"…remove(current)fatal: not a git repository, exit 1fatal: not a git repository, exit 1-C "$p"…remove(this PR)✓ Removed … worktree & branch, exit 0✓ Removed … worktree & branch, exit 0With
CLAUDE_PROJECT_DIRset to the repository, both forms succeed — this PR only changes the case where it isn't.Knock-on: which
.config/wt.tomlsupplies the removal hooksThe anchor also selects the project config that
pre-remove/post-removeare 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.tomlinside its own worktree: those commands are then unapproved, and because the hook's stdin isn't a TTY,prompt_for_batch_approvalreturnsNotInteractiveandwt removeexits 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.jsonchange with the test file untouched. On review it's a fair point that this is the fourth change to these two commands andtest_plugin_layout_is_consolidatedis where each prior fix was pinned, so the second commit adds an assertion there: theWorktreeRemovecommand must resolve via-C "$p", and must not depend onCLAUDE_PROJECT_DIR. It sits next to the existing-Dandpipefailchecks onworktree_remove_cmd, and fails against the pre-fixhooks.json.The existing assertions still hold: every hook command references
$CLAUDE_PLUGIN_ROOTunbraced — unchanged; the WorktreeRemove command contains no-D/--force-delete—-Cis neither.test_claude_hook_commands_parse_in_all_shellschecks the outer command line under fish/zsh/bash, and the edit stays inside the single-quotedbash -cbody.Alternatives considered
cd "$p"rather than-C "$p"— also works, but it moves the shell into the directory that is about to be deleted.-Cleaves the cwd alone and matches howwt step prune -Cis already used elsewhere.CLAUDE_PROJECT_DIRas 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 andwt removethen finds nothing to remove. That is orthogonal to the anchor — tracked separately in #3753.