Skip to content

fix(shell): validate command names and tighten install/uninstall detection#2864

Open
max-sixty wants to merge 33 commits into
mainfrom
clawpatch-shell
Open

fix(shell): validate command names and tighten install/uninstall detection#2864
max-sixty wants to merge 33 commits into
mainfrom
clawpatch-shell

Conversation

@max-sixty

@max-sixty max-sixty commented May 21, 2026

Copy link
Copy Markdown
Owner

Two shell-integration correctness fixes plus an uninstall-scope redesign.

Command-name validation. The shell-integration command name comes from the user-typed --cmd flag, or argv[0] when --cmd is omitted. A malformed value — an empty string, a leading -, or characters like ; or whitespace — renders into a broken shell rc line that the user then has to find and fix. validate_shell_command_name() rejects empty, leading--, and non-[A-Za-z0-9._-] names at the CLI entry points, so a bad value produces a clear error up front instead of a silently broken config file. This is input hygiene on a value the user already controls — not a security boundary.

Noncanonical-line detection on install. Install recognized only its exact current canonical line, so a manually-added or older-form integration line (e.g. eval "$(wt config shell init zsh)") was not seen as already-present and a duplicate was appended on the next install. It now detects those forms and reports already-configured.

Uninstall scans every worktrunk-managed file, regardless of cmd. Previously uninstall located Fish/Nushell wrapper files by name ({cmd}.fish / {cmd}.nu), so removing integration installed under an alternate binary name needed a matching --cmd. That symmetry leaked an implementation detail — uninstall already has a content marker to recognize worktrunk wrappers regardless of binary name. It now scans the wrapper directories (~/.config/fish/functions, conf.d, completions; the nushell candidates' vendor/autoload) and admits every file whose content matches the cmd-agnostic marker # worktrunk shell integration for <shell> (always emitted by the wrapper templates), with a regex fallback for older installs. For Bash/Zsh/PowerShell (line-based), is_shell_integration_line_for_uninstall_any_cmd extracts binary-name candidates from each line and reuses the execution-context check. The --cmd flag is removed from wt config shell uninstall; install keeps it (installing under an alternate name is still a deliberate per-cmd act).

Covered by new integration tests: custom-cmd install + cmd-less uninstall round-trips for zsh/fish/nushell (verifying scan-all removes the custom-cmd integration), malformed-name rejection at the CLI edge (--cmd and argv[0]), and the older-line dedup case.

This was written by Claude Code on behalf of Maximilian Roos

max-sixty and others added 3 commits May 20, 2026 22:03
`wt config shell init/install/completions --cmd <name>` rendered the
command name verbatim into eval'd shell code and shell-owned file paths.
A name like `wt; touch /tmp/pwn` injected arbitrary commands into the
generated integration line a user would eval from their rc file.

`validate_shell_command_name` is now the single validator: it rejects
empty names, leading `-`, and anything outside ASCII alphanumerics plus
`.`, `_`, `-`. CLI entry points (`handle_init`, `handle_completions`,
each `configure_shell` handler) validate before generating any shell
code or touching rc files; `ShellInit::with_prefix`, `config_line`, and
the `paths` helpers carry the check as a structural guard.

Co-Authored-By: Claude <noreply@anthropic.com>
`configure_shell_file` only recognized a byte-exact match of the current
canonical integration line. A `.zshrc` carrying a manually-added or
older-version line (e.g. bare `eval "$(wt config shell init zsh)"`
without the `command -v` guard) was not detected as already configured,
so install appended a second, duplicate invocation.

Install now classifies an existing line via the shared
`is_shell_integration_line` predicate, scoped to the shell being
installed (`config shell init <shell>`), so semantically equivalent
managed lines count as already configured.

Also collapses the `is_diagnostic_file` body to the rustfmt-canonical
single line (an unformatted line slipped into the preceding logs commit).

Co-Authored-By: Claude <noreply@anthropic.com>
`wt config shell install --cmd git-wt` installs integration under an
alternate command name, but `uninstall` had no `--cmd` flag and always
used `binary_name()`. A custom-name install could not be removed through
the CLI — the user had to edit rc files by hand.

`Uninstall` now takes `--cmd` (optional, defaulting to the binary name)
matching `Install`, passed through to `handle_unconfigure_shell`. Removal
for bash/zsh and the fish/nushell wrapper files all honor the custom
name.

Co-Authored-By: Claude <noreply@anthropic.com>

@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.

codecov/patch is failing: the invalid_command_name_error helper added at the top of src/shell/paths.rs is uncovered (3 lines / 50% patch coverage on that file).

It only fires when one of the three paths.rs accessors (config_paths, legacy_fish_conf_d_path, completion_path) is called with an invalid cmd — but every CLI entry point validates first, so the structural guard is only exercised via Shell::is_shell_configured() / scan_for_detection_details(), both of which feed it the already-validated binary_name(). Per CLAUDE.md the patch check gates merge. Two reasonable fixes:

  • Add a unit test in the existing mod tests block at the bottom of paths.rs calling one of the three accessors with "wt; touch" and asserting ErrorKind::InvalidInput.
  • Inline the one-call-site-each std::io::Error::new(...) into the three .map_err(...) calls and delete the helper.

Either works — the test path also documents the contract.

Also flagged a small inline below.

Comment thread tests/integration_tests/init.rs
Address reviewer feedback on PR #2864:

- Add a unit test in paths.rs `mod tests` exercising the three
  `cmd`-taking accessors (`config_paths`, `legacy_fish_conf_d_path`,
  `completion_path`) with an unsafe name, asserting `InvalidInput`.
  This covers the previously uncovered `invalid_command_name_error`
  helper (the `codecov/patch` gap) and documents the contract.
- Add `zsh` to `test_init_rejects_unsafe_cmd` so the rejection test
  covers the same shells as the adjacent `test_init`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`config_line()` and `ShellInit::with_prefix()` re-validated `cmd` with
`panic!`, but every production caller already validates at the command
entry point (`handle_init`, `handle_configure_shell`,
`handle_unconfigure_shell`, `handle_completions`) via the
`Result`-returning `validate_shell_command_name`. The panic was a weaker
second mechanism guarding an invariant the edge validation already
enforces — belt-and-suspenders. Per CLAUDE.md "one mechanism per
guarantee", keep the graceful `Result` at the edge and delete the panic.
Docstrings now name the trust boundary so callers know `cmd` must be
pre-validated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@max-sixty max-sixty closed this May 21, 2026
@max-sixty max-sixty deleted the clawpatch-shell branch May 21, 2026 22:44
@max-sixty max-sixty restored the clawpatch-shell branch May 21, 2026 22:45
@max-sixty max-sixty reopened this May 21, 2026
max-sixty and others added 17 commits May 22, 2026 13:25
The three paths.rs accessors (config_paths, legacy_fish_conf_d_path,
completion_path) re-validated the command name, but every caller is a
command handler that already validates at the edge. Per CLAUDE.md's
"one mechanism per guarantee", drop the redundant internal guard, its
io::Error helper, and the unit test covering it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## Summary

`wt switch --clobber` backs up an existing path before clobbering it.
The old code checked `backup_path.exists()` and then, later, called
`std::fs::rename` — which silently overwrites an existing destination on
every platform. Anything that appeared at the backup path between the
check and the rename (a same-second clobber, or a path that raced in)
was destroyed: a time-of-check/time-of-use data-loss race on a
`--clobber` operation.

## The fix

The backup now moves with `renamore::rename_exclusive` — an atomic,
no-overwrite rename: `renameat2(RENAME_NOREPLACE)` on Linux,
`renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` on Windows. It fails
closed (`AlreadyExists`) rather than overwriting. `renamore` keeps the
platform FFI — and its `unsafe` — inside the crate, so worktrunk itself
stays `unsafe_code = "forbid"`.

`back_up_clobbered_path` moves the stale path with that primitive. If
the timestamped backup name is already taken, it counts up — `…-2`,
`…-3`, … — until it finds a free name, so a collision no longer fails
the command and an existing backup is never overwritten. The loop
terminates because a directory holds finitely many entries; genuine I/O
errors still surface as errors.

The timestamp and the move now both happen at execution time, which also
closes a smaller stale-plan window — the backup path was previously
computed during planning and renamed much later.

## Key files

- `src/commands/worktree/resolve.rs` — `back_up_clobbered_path` retry
loop.
- `src/commands/worktree/switch.rs` — `execute_switch` uses it; the plan
now carries `needs_clobber_backup: bool` instead of a resolved path.

## Notes

- `renamore` is a new dependency. `rustix` / `windows-sys` are
deliberately not added as direct dependencies — calling either's rename
API requires an `unsafe` block, which this crate forbids; `renamore`
encapsulates that.
- `wt step relocate --clobber` has the identical
check-then-`std::fs::rename` race. It is **not** fixed here — a separate
branch handles relocate; when that lands it should adopt
`renamore::rename_exclusive`.

## Testing

`back_up_clobbered_path` has unit tests covering every branch — fresh
move, `-N` fallback, many collisions, non-`AlreadyExists` error.
Integration tests cover `wt switch --clobber` end-to-end, including the
collision fallback (which runs on the Windows CI job).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A command-keyed list of wire-reaching commands missed entry points. The interactive `wt switch` picker generates LLM branch summaries (`preview_orchestrator.rs` `spawn_summary`), and `wt merge` runs an intermediate commit through the same `commit.generation` path as `wt step commit`.

This replaces the two command-attributed LLM rows ("`wt list` — LLM summaries", "`wt step commit`") with activity-based entries: "generating a branch summary" and "generating a commit message" with a `commit.generation` command. Activities cover every entry point without enumerating commands. The header becomes "What currently reaches the wire" since two entries are now activities rather than commands.
…2853)

The `nix-flake` CI job was red on `main`: the unit test
`commands::hook_plan::tests::approve_readonly_drops_unapproved_project_keeps_user`
panicked with `Failed to create config directory: Permission denied`.

The test called `approve_command(.., None)`. The `None` for
`approvals_path` makes `Approvals` resolve the path through
`approvals_path()`, which — in the lib crate compiled in non-test mode —
points at the real `~/.config/worktrunk/` directory. `approve_command`
then `create_dir_all`s it. Outside a sandbox `$HOME` is writable, so the
test silently passed while writing a stray `[projects.proj]` entry into
the user's `approvals.toml`. The nix build sandbox has no writable
config dir, so it panicked instead.

This was latent on `main` since #2806 (which added the test). It only
started tripping the gate now because the `nix-flake` job runs when a PR
touches `Cargo.{toml,lock}`, and #2849 was the first to do so — so
`nix-flake` failed there for a reason unrelated to that PR's change.

The fix points `approve_command` at a `tempfile::tempdir()`-backed
approvals path via the `Some(&path)` parameter it already accepts — no
process-env mutation (`tests/CLAUDE.md` bans it), matching the in-file
pattern in `src/config/approvals.rs`'s own tests. Same class of fix as
#2615 and #2624.

The two sibling tests in `hook_plan.rs` only *read* via
`Approvals::load()`, which returns the default when no file exists —
never creating a directory or writing — so they pass in the sandbox
unchanged and were left as-is.

Verified by reproducing the sandbox condition locally (test binary run
with `HOME` / `XDG_CONFIG_HOME` pointed at a non-writable dir): the
target test fails before the fix and passes after, and all three
`hook_plan` tests still pass under a plain `cargo test`.

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

Correctness and data-safety fixes for worktrunk's config
deprecation/migration layer and hook-log layout — surfaced by an
automated `clawpatch` review and re-reviewed against the
`reviewing-code` checklist.

## Fixes

- **Deprecated config sections are no longer dropped when the parent is
an inline table.** With `commit = { stage = "tracked" }` alongside a
deprecated `[commit-generation]`, migration removed the old section but
silently failed to write `[commit.generation]` — `as_table_mut()`
returns nothing for an inline table, so the nested insert was skipped
after the source had already been removed. Migration now converts an
inline-table parent to a standard table before inserting. Real data loss
without this.

- **Deprecated template variables are rewritten only inside template
tags.** The old `\bword\b` regex rewrote any occurrence of a deprecated
name — literal command text (`echo repo_root`), `{% set %}` locals,
member access — not just genuine `{{ … }}` references. That could
corrupt a command on migration, and could collapse two distinct commands
to the same normalized string so an un-approved command matched an
approved one. The rewrite is now gated on `minijinja`'s parser
(`undeclared_variables`) plus a delimiter-aware scan, so only real
deprecated references inside `{{ }}`/`{% %}` are rewritten. This is the
fix #2841 deliberately deferred to a sibling branch.

- **Repo-wide internal logs are written as top-level files.**
Branch-agnostic internal-operation logs were written into a top-level
`internal/` directory, violating the layout invariant that top-level
directories are per-branch trees — `wt config state` would miscategorize
`internal/` as a branch. They now write to `internal-{op}.log` files
alongside the other shared logs.

- **System config is routed through the deprecation gate.** System
config used a silent migrate path; it now goes through
`check_and_migrate` like user config, so a deprecation in system config
surfaces warnings consistently.

## Scope

Pure code — the clawpatch review tooling and its state files are
excluded.

## Testing

`cargo run -- hook pre-merge --yes` — 3825 tests pass, lints clean.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…2857)

Per @max-sixty's [direction in
#2838](#2838 (comment)):
revert the docs portion of #2840 and keep the code. Docs continue to
recommend `pre-start`/`post-start`; both names work in code so anyone
who already followed the briefly-changed docs (e.g. @EcksDy) isn't
stranded once a release ships these aliases.

## User-visible — back to `pre-start`/`post-start`

- README, docs site, skill mirrors, `dev/*.example.toml`,
`plugins/worktrunk/README.md`, `flake.nix`, `.config/wt.toml`
- `src/cli/mod.rs` / `src/cli/config.rs` / `src/cli/step.rs` /
`src/help.rs` after_long_help and example snippets — and the auto-synced
`docs/content/` and `skills/worktrunk/reference/` mirrors
- `wt hook --help` canonical subcommand names; completion advertises
`-start` only
- `HookType` Display via strum, serde `rename`, and clap `ValueEnum`
name — all `pre-start`/`post-start`. The Rust variant identifiers stay
`PreCreate`/`PostCreate` (internal; we already paid for that rename in
#2840, and now the eventual flip is a Display-only change)
- `HooksConfig` serde canonical fields

## `*-create` still works (kept code)

- `wt hook pre-create` / `post-create` — CLI alias on the canonical
subcommand
- `pre-create` / `post-create` in config: top-level, `[hooks.*]`, and
per-project, in string, `[table]`, and `[[array-of-tables]]` form.
Mechanism: serde `alias = ...` on the field, plus a silent in-memory
rename in `migrate_content()` so the round-trip in `unknown_tree`
doesn't flag table forms as schema-unknown.
- The pre-0.32.0 `post-create` fatal-load-error machinery stays removed
— the name is reclaimed, and both forms load without error.

## Smaller bits

- `valid_user_config_keys()` / `valid_project_config_keys()` append
`pre-create` / `post-create` so the unknown-field round-trip skips them.
`test_valid_*_keys_all_deserialize` skips both aliases (they can't sit
alongside the canonical without a duplicate-field error).
- `DEPRECATED_SECTION_KEYS` drops the `pre-start`/`post-start` entries
#2840 added — `pre-start`/`post-start` are canonical again.
- `find_pre_start_from_doc` / `find_post_start_from_doc` /
`find_renamed_hook_key` / `is_non_empty_item` /
`migrate_start_hooks_doc` and their tests are removed; the migration
direction flips via a new `migrate_create_hooks_doc` (silent, mirrors
the prior shape).
- Test files `e2e_shell_post_create.rs` and `post_create_commands.rs`
rename back to `_post_start_` (via `git mv`, so the rename shows as a
rename).

## Testing

`cargo run -- hook pre-merge --yes` — 3806 tests pass; the 10 failures
are all `case_4` of `shell_wrapper::unix_tests::*` (nu-shell case; `nu`
isn't installed in this runner; same failures occur on `main`).

Also manually verified that a fresh `wt switch --create` against a
project config with `[post-create]` loads cleanly with no unknown-field
warning and the hook fires as `post-start`.

## Follow-up

Per @max-sixty: in a couple of weeks, once a release with
both-names-work is out and users have had a chance to upgrade, the docs
flip is straightforward (most of it is in `src/cli/mod.rs`'s
`after_long_help` and the doc-sync test propagates).

Re #2838.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds eight clawpatch feature specs and refines feat_custom_network_boundary to be activity-based (matching the Network Access reword in #2850). Config-only — no code change.
…#2858)

## Summary

`wt switch <branch>` and the interactive picker (`wt switch` with no
argument) each ran the same switch sequence as separate, parallel code.
[#2845](#2845) made the two
paths *behave* identically; this makes the *code* identical too — a
single `SwitchPipeline` that both entry points build and `.run()`.

`SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the
whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks,
source-identity capture, `plan_switch` → `approve_switch_hooks` →
`validate_switch_templates` → `execute_switch`, output, background
hooks, and `--execute`. Each caller now only resolves a branch
identifier and loads config. The picker-vs-argument differences
(`--execute`, the shell-integration offer, source-identity capture) are
struct field values, not divergent branches.

## Bug fixed

The duplication hid a real bug: the picker passed `yes = true` to
`run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks
without a prompt** — unapproved code from a freshly cloned
`.config/wt.toml` running silently. Every other hook the picker runs
(`post-switch`, `pre-create`, `post-create`) already went through the
approval prompt. With one shared `run_pre_switch_hooks` call gated by
the pipeline's single `verify`/`yes` pair, the picker (which has no
`--yes`) now prompts for project `pre-switch` hooks like `wt switch
<branch>` does — and the two paths can't drift on hook approval again.

## Reviewer notes

- One intentional reorder on the argument path:
`offer_bare_repo_worktree_path_fix` now runs before
`run_pre_switch_hooks` (the picker already used this order). The fix
only mutates `worktree-path` config, which pre-switch hooks never read —
behavior-neutral.
- `capture_switch_source` runs inside `run()` after pre-switch hooks —
the same relative position as the old `run_switch`.
- Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to
private; `worktree/mod.rs` re-exports trimmed accordingly.

## Testing

Pure refactor — the existing `test_switch_*`,
`test_switch_format_json_*`, and `test_switch_picker_*` suites cover
behavior preservation.
`test_switch_picker_pre_switch_hook_requires_approval` is a new
regression test for the bug fix (verified to fail under the bug).

---------

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

A future release will switch `wt switch --execute` (`-x`) to an argv
input model: a single program name, with arguments after `--` passed
verbatim, run via `execvp` with no implicit shell. That is a breaking
change to a protected CLI interface, so it ships as a two-release
deprecation — **this PR is the warn phase.**

`validate_switch_templates` now emits a deprecation warning when the
`-x` value is not a single program token — it contains shell syntax,
multiple words, or `{{ }}` template markup. The hint shows the concrete,
copy-pasteable migration:

```
▲ --execute will change in a future release: it will run a single program,
  with arguments after --, not a shell command line
↳ To run this command line unchanged, pass it to a shell:
  --execute sh -- -c 'echo hi && ls'
```

`sh` is itself a single program token, so the suggested form works
today, does not warn, and survives the cutover. A single program name —
including a path — stays silent.

The `-x` help examples in `src/cli/mod.rs` move to the argv-compatible
form (`-x code -- '{{ worktree_path }}'`) so the docs no longer
recommend a form that warns.

## Why no PATH check

The classifier is purely structural — it decides whether the value is
one bare program token, nothing more. It deliberately does not check
whether a bare name resolves to a real executable. An earlier iteration
did, to also warn on `-x my-alias`, but a PATH lookup at pre-flight is
environment-sensitive, runs in the source worktree rather than where
`-x` will execute, and cannot distinguish a shell alias from a typo or
an uninstalled tool. A bare alias/function `-x` is left to fail loudly
(`execvp` → `ENOENT`) at the cutover rather than guessed at here. The
warning still catches every multi-word / shell-syntax form, which is
what users actually write.

## Testing

`cargo run -- hook pre-merge --yes` — 3799 tests pass; clippy, fmt, and
pre-commit hooks green. New: a unit test for the token classifier and an
integration test covering the warn and no-warn cases. Most of the
32-file diff is snapshot regeneration — the warning is new stderr output
on existing `-x` tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `Approvals` and `UserConfig` mutation methods took an
`Option<&Path>`: `None` meant "resolve the global config path",
`Some(p)` meant "use `p`". `None` was a footgun. A unit test that passed
`None` silently wrote to the developer's real `~/.config/worktrunk/`: it
passed wherever `$HOME` was writable and failed only in a sandbox with
no writable config dir. That is the class of bug behind the recent
`nix-flake` failure (#2853).

This drops the `Option`. All ten mutation methods
(`Approvals::{approve_command, approve_commands, revoke_project,
clear_all, with_locked_mutation}` and
`UserConfig::{set_skip_shell_integration_prompt,
set_skip_commit_generation_prompt, set_project_worktree_path,
set_commit_generation_command, with_locked_mutation}`) now take `&Path`.
Production callers resolve the path explicitly through two new helpers:
`require_approvals_path()` and `require_config_path()`, the
`Result`-returning counterparts of `approvals_path()` / `config_path()`,
matching the `require_*` accessor convention. A test can no longer reach
the real config dir without typing the path; the resolver is no longer a
silent default.

`HookPlan::approve` also skips `Approvals::load()` on the `--yes` path.
That path approves every project command unconditionally and persists
nothing, so the load was dead work. Skipping it removes a useless I/O
and keeps `wt merge --yes` working when `approvals.toml` is malformed.

`tests/CLAUDE.md` gains a "Config Isolation for In-Process Unit Tests"
section; the `approvals_path()` doc is updated to describe the
lib-crate-only `#[cfg(test)]` guard.

## Testing

Full pre-merge gate green (3811 tests, fmt, clippy, doctests). The three
`hook_plan` tests pass under a non-writable-`HOME` sandbox repro of the
build-sandbox condition.

One coverage note: `require_approvals_path()` / `require_config_path()`
each have an error branch (`ok_or_else(|| ConfigError("Cannot determine
…"))`) that fires only when no config directory is resolvable. CI always
sets `$HOME` / `WORKTRUNK_*_PATH`, so that branch is unreachable in
tests, and `codecov/patch` may flag those two lines. The same closure
existed before this change, inside `with_locked_mutation`; the refactor
moves it into the new helpers, where it counts as patched lines.

---------

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

Follow-up to #2852. The `--execute` deprecation warning tells users how
to migrate a shell command line to the upcoming argv form, but gave no
channel for users whose workflow the argv cutover would genuinely
regress to push back before it lands.

This adds the B2 tracking issue (#2860) to the migration hint. To keep
the deprecation at one warning + one hint, the issue link is merged into
the single existing hint line rather than added as a second `↳`:

```
▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line
↳ Comment at #2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls'
```

The two suggestions are semicolon-joined per the writing-user-outputs
"multiple suggestions in one hint" pattern; the migration command stays
last so it remains the easy copy target, and the URL stays clickable
mid-line. The 26 `--execute` snapshots gained the URL mechanically.

Ref #2860.

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ath (#2872)

`require_config_path()` (added in #2862) and the older
`require_user_config_path()` in `src/commands/config/state.rs` both just
wrapped `config_path()` with a `Result`. Two functions, two slightly
different error messages, one purpose.

This deletes `require_user_config_path()` and points its four callers —
`config create` (×2) and `config show` (×2) — at the lib-crate
`require_config_path()`.

The two wrapper tests in `config/mod.rs` are removed with it.
`test_require_user_config_path_returns_ok` tested a trivial wrapper.
`test_require_user_config_path_matches_config_path` guarded issue #1134
(config-create resolving a *different* path than config-loading) — that
regression is now structurally impossible: `config create` / `show`
resolve through `require_config_path()`, which is `config_path()`
itself, so there is no separate resolver left to diverge.

One behavior delta: when no config directory can be resolved, `config
create` / `config show` now error `Cannot determine config directory.
Set $HOME or $XDG_CONFIG_HOME` instead of `Cannot determine config
directory`. No snapshot test references either string.

Follow-up to #2862.

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

The Claude Code statusline runs `wt list statusline` as a subprocess
with all three standard streams piped, so `terminal_width()` can't
detect the terminal directly and `detect_parent_tty_width()` walks the
process tree to find a TTY. That fallback reserved 20% of the detected
width for Claude Code's own UI messages, which scales badly: on a
200-column terminal it gave up 40 columns, far more than the chrome
needs. This reserves a fixed 5 columns instead, so wide terminals keep
nearly all their width and narrow ones still get a sensible margin
(`saturating_sub` guards against terminals under 5 columns).

Also adds a weekly tend maintenance task that scans the agent CLIs
Worktrunk integrates with (Claude Code, Codex, Gemini CLI, OpenCode) for
changes to the surfaces Worktrunk depends on — the statusline JSON
schema, worktree-lifecycle hooks, and plugin install mechanisms — and
files an issue when something relevant changes.

---------

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

The FAQ and the `wt remove --force` help text said `--force` overrides
the untracked-files check "for build artifacts". `--force` actually
removes a dirty worktree including staged and modified *tracked* files,
not just untracked ones (`test_remove_force_with_modified_files`,
`test_remove_force_with_staged_files`). On a destructive command, that
wording can lead users to consent to more data loss than they expected.

Updates the `--force` arg help, the "Force flags" table and example in
`src/cli/mod.rs`, and the FAQ to say `--force` discards staged,
modified, and untracked files; the `help_remove_long` snapshot and the
`remove.md` / `faq.md` doc and skill mirrors are regenerated to match.

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The deprecated `--no-verify` flag was declared as a clap arg five times
— across `SwitchArgs`, `RemoveArgs`, `CommitArgs`, `SquashArgs`, and
`MergeArgs` — under two field names, resolved through two helpers, with
the deprecation-warning string hand-duplicated in two places.

This collapses the four bool-resolving commands (`switch`, `remove`,
`step commit`, `step squash`) onto a single flattened `HookFlags` args
struct with one `resolve()` method, and routes every `--no-verify`
deprecation warning — merge's included — through one emitter, so the
warning text exists in exactly one place.

`wt merge` keeps its own flags: its hooks flag is genuinely tri-state
(`Option<bool>`, so config `[merge] verify` still applies) with a
positive `--verify` override, which `HookFlags`'s
`SetFalse`/default-true shape cannot express. It shares only the warning
emitter — forcing it into the struct would need a special case.

Behavior is unchanged: `--no-verify` still works as a deprecated alias
and emits the same warning under the same conditions. One visible
`--help` change, a direct consequence of the shared struct: `--no-hooks`
for `step commit`/`step squash` moves under the `Automation` heading,
matching where `switch`/`remove`/`merge` already place it. Doc mirrors
regenerated.

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
An audit of `wt`'s user-visible strings against the repo's
`writing-user-outputs` skill. Six consistency corrections, each matching
a rule the skill states — and, in most cases, a sibling message that
already follows it:

- `RefCreateConflict` hint: dropped the cross-message pronoun ("it") and
switched to the skill's `"To X, run Y"` command-hint form.
- `"All shells already configured"` and the version-check `"Up to
date"`: `success ✓` → `info ○` — both acknowledge existing state without
changing anything.
- `relocate` summary: keyed the message type on whether anything was
*relocated*, not on whether anything was *skipped* (the same outcome was
rendered two ways).
- `"Diagnostic saved"`: → `success` (a file was created) and the
`@`-before-path convention.
- Removed a stray trailing period — the only single-line message in the
codebase that had one.

`--no-verify` is deliberately untouched (handled in the
`canonical-paths` PR). Snapshot tests updated.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
max-sixty and others added 8 commits May 23, 2026 11:29
`wt switch --clobber` and `wt step relocate --clobber` each had their
own logic for backing up a path blocking the target, and the two had
diverged. relocate used an `exists()` check followed by
`std::fs::rename` — a time-of-check/time-of-use race, since
`std::fs::rename` silently replaces an existing file or empty directory
on Unix and could destroy a just-created backup. switch (#2849) instead
used `renamore::rename_exclusive`, an atomic no-overwrite rename, and
counted up through suffixes (`…-2`, `…-3`, …) on a name collision rather
than failing.

This PR replaces both with one shared helper in `commands::backup`.
`back_up_clobbered_path_now` computes the timestamped name and delegates
to the suffix-iterating `back_up_clobbered_path`, which moves the
blocker with `renamore::rename_exclusive` — atomic and no-overwrite, so
an existing backup is never clobbered; a name collision just lands on
the next free `-N` name. relocate gains the suffix fallback (previously
it failed closed on `AlreadyExists`), the timestamp computation is no
longer duplicated across the two call sites, and
`renamore::rename_exclusive` now has a single caller.

`renamore` is already a direct dependency and keeps the platform FFI and
its `unsafe` inside that crate, so worktrunk stays `unsafe_code =
"forbid"`.

relocate's backup name changes from `.bak-<timestamp>` to the
extension-aware `.bak.<timestamp>` form used by switch (e.g.
`repo.feature` → `repo.feature.bak.<timestamp>`). The
`test_relocate_clobber_error_backup_exists` regression test asserted the
old fail-closed behavior and is rewritten as
`test_relocate_clobber_falls_back_when_backup_taken`, mirroring the
switch test. CLI help and the generated doc mirrors are updated for the
new naming and fallback.

The shared helper carries the suffix-iteration, collision, and
missing-source unit tests (moved from `resolve.rs`); the relocate
`--clobber` path keeps its integration coverage.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Worktrunk resolved each hook's `.config/wt.toml` from a different
worktree depending on the hook — `post-merge` from the merge target,
`post-switch` from the destination, `pre-remove`/`post-remove` from each
removed worktree, `wt step prune` from each prunable worktree, and `wt
switch --create` from the base ref's *committed* config via `git show`.
That last one is the bug behind #2856 and #2818: an uncommitted or
branch-local `.config/wt.toml` silently failed to fire creation hooks,
and `wt config show` (which reads the working tree) disagreed with what
actually ran.

This replaces all of it with one rule: **every hook resolves its
commands from the `.config/wt.toml` of the worktree `wt` ran in** — the
invoking worktree, read from its working tree, the same file `wt config
show` displays.

## Behavior changes

- `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking
worktree's config, so an uncommitted `.config/wt.toml` fires them; the
base ref's or PR's committed config is no longer consulted.
- `post-merge` runs the feature worktree's config, not the merge
target's.
- `post-switch` into an existing worktree uses the source, not the
destination.
- `wt remove <other-branch>` and `wt step prune` use the invoking
worktree's config, not each removed worktree's.

In the common case — a committed, repo-wide `.config/wt.toml` — these
are identical; they diverge only when a branch carries its own
working-tree edits.

## For reviewers

The module docstring in `src/commands/hooks.rs` is the spec — its
per-hook config-source table collapsed to one rule. The change is
concentrated in five approval gates that now call
`repo.load_project_config()` once instead of
`Repository::at(<other-worktree>)`: `merge::approve_merge_plan`,
`main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`,
`picker::approved_removal_plan`, and `worktree::switch`. The
`switch_hook_project_config` helper and the `base_ref_for_create` /
`project_config_at_ref` `git show` machinery are deleted. The *anchor* —
the worktree a hook runs in, the executor's plan-lookup key — is
unchanged; only the config *source* unifies. The frozen
`ApprovedHookPlan` still closes the approval-boundary TOCTOU.

## Testing

Hook config-resolution tests across `switch`, `merge`, `remove`, and
`step_prune` were rewritten to assert the new rule, each also checking
that the non-invoking worktree's config is ignored.
`test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU
regression: a `post-merge` that enters the invoking worktree's config
only via the rebase, after the gate froze the plan, must not run.

Ref #2856, #2818.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The interactive picker's alt-r removal signal carried each row's branch name; detached worktrees all report `(detached)`, so two detached rows collided and alt-r could remove the wrong one. Worktree-backed rows now identify by their unique `worktree-path:<path>` token, decoded to an exact removal/switch target; branch-only rows keep the branch name.
Release v0.53.0 — version bump and changelog for the 48 commits since
v0.52.0.

Highlights:

- **`wt switch --execute`** enters the warn phase of its move to an argv
input model — a shell command line as the `-x` value now warns, with a
copy-pasteable migration.
- **`wt config show`** reports the project identifier (the key for
`[projects."…"]`); new Gemini CLI extension detection.
- **Hooks resolve project config from the invoking worktree** — an
uncommitted or branch-local `.config/wt.toml` now fires creation hooks.
- Correctness and data-safety fixes: atomic no-overwrite `--clobber`
backups, wedged/orphaned fsmonitor daemon reaping, squash-merge
detection immune to `diff.*` git config, and config-migration data-loss
fixes.

Full details in the `## 0.53.0` section of `CHANGELOG.md`.

`cargo-semver-checks` reports breaking library API changes (renamed
`HooksConfig` fields, removed `Repository::project_config_at_ref`), so
this is a minor bump per the project's pre-1.0 versioning.
Follow-up to #2857. That PR reverted the docs half of the
`pre-start`/`post-start` → `pre-create`/`post-create` hook rename while
keeping the code that accepts both names. Its self-review landed 26
seconds after the merge, so none of its findings were addressed —
several tests still described the pre-revert direction and exercised
only canonical names, so they would pass even if the deprecated alias
broke.

## Changes

The three deprecated-alias tests now use `pre-create`/`post-create` in
their fixtures (or invoke `wt hook pre-create`), so they exercise the
migration and the CLI alias rather than canonical names:
`test_hook_show_accepts_deprecated_create_hooks`,
`test_deprecated_create_hook_key_runs_silently`,
`test_standalone_hook_create_alias_runs_silently`.

`test_parse_hook_type_aliases` had the same defect — it parsed only the
canonical names — and now parses both forms and asserts they map to the
same `HookType`.

Added unit tests for `migrate_create_hooks_doc` (every value shape,
per-project tables, skip-when-canonical-exists, invalid TOML) plus a
migration-diff snapshot; the revert removed the
`migrate_start_hooks_doc` tests with no equivalent.

Deleted `Deprecations::pre_start`/`post_start` — always-false dead
fields once the revert removed their detection.

Deleted `test_config_show_displays_start_hook_migration`: post-revert
there is no detection for `pre-create`, so `wt config show` shows no
migration diff and the test verified nothing (its regenerated snapshot
confirmed this).

Also corrected reversed-direction comments in `deprecation.rs` and
`hook_commands.rs`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small follow-ups noticed while fixing the test comments in #2877.

**`pre_create` docstring was wrong.** It read "Commands to execute
before worktree creation (blocking)", but `pre-start` actually runs
*after* the worktree is created (`switch.rs:1142` executes it against
"the new worktree (created just above)", and the renamed test below
asserts the worktree exists after a failing pre-start). The wrong
wording entered in #2840 as part of the mechanical `pre-start` →
`pre-create` rename — someone "corrected" the docstring to match the new
name. #2857 reverted the name but left the docstring. Restoring "after
worktree creation (blocking, fail-fast)" matches the actual behavior,
`docs/content/hook.md` ("Runs once when a new worktree is created,
blocking…"), and the parallel `post_create` docstring. Because
`HooksConfig` derives `JsonSchema`, this docstring is user-visible in
generated schema descriptions.

**`test_user_post_start_hook_failure` was misnamed.** The fixture is
`[pre-start]`, the comment and assertion describe a failing pre-start,
and only the function name said post-start. Renamed to
`test_user_pre_start_hook_failure` along with its snapshot name and file
(`git mv`, so the rename shows as a rename).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Uninstall previously located Fish/Nushell wrapper files by name
(`{cmd}.fish` / `{cmd}.nu`), so removing integration installed under an
alternate binary name (e.g. `git-wt`) required the user to pass `--cmd`
matching the install. That symmetry leaks an implementation detail —
uninstall already had the content-marker (is_worktrunk_managed_content)
needed to recognize wrapper files regardless of name.

Uninstall now scans the wrapper directories (~/.config/fish/functions,
conf.d, completions; the nushell config candidates' vendor/autoload) and
admits every file whose content matches the cmd-agnostic marker —
`# worktrunk shell integration for <shell>` (always written by the
wrapper templates), with a regex fallback for older installs. For
Bash/Zsh/PowerShell (line-based integration), is_shell_integration_line_for_uninstall_any_cmd
extracts binary-name candidates from each line and reuses the
execution-context check.

`--cmd` is dropped from `wt config shell uninstall`; `install` keeps it
(installing under an alternate name is still a deliberate per-cmd act).
Per-cmd uninstall scoping is removed (niche use case; `uninstall <shell>`
already provides coarse scoping by shell).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@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.

Two small concerns on the new uninstall scan-all logic — neither blocks, both are worth a look.

The is_shell_integration_line_for_uninstall_any_cmd regex extracts only wt from eval "$(git wt config shell init bash)" and eval "$(command git wt config shell init bash)", but is_valid_command_position then rejects it because the wt is preceded by git . So lines installed in the git wt … (separate-words) form — explicitly supported as a git-wt invocation in is_shell_integration_line_impl and exercised in detection.rs's #[case::git_space_wt] / #[case::command_git_wt] tests — are no longer removed by uninstall. The hyphen form (git-wt …) which install actually emits still works, so this only bites users with hand-edited rc files. Lifting the candidate to git-wt when the regex match sits behind git recovers the old behavior.

Separately, fish_wrapper_names: HashSet<String> (collected at line 994 and iterated at line 1111) is order-sensitive only when more than one worktrunk-managed Fish wrapper exists in the same install — every current test has at most one, so this isn't biting yet, but the completion-removal display order would flake. BTreeSet is a one-token fix.

let mut not_found = Vec::new();
// Wrapper file names found (e.g. `wt.fish`, `git-wt.fish`); the matching
// completion files in `completions/` share these names.
let mut fish_wrapper_names: HashSet<String> = HashSet::new();

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.

Suggested change
let mut fish_wrapper_names: HashSet<String> = HashSet::new();
let mut fish_wrapper_names: BTreeSet<String> = BTreeSet::new();

HashSet iteration is non-deterministic, so when more than one worktrunk-managed Fish wrapper exists the order of completion_results and the Removed … lines printed for completions depends on the hash seed. BTreeSet (with the matching import swap) gives a stable order and the API is otherwise identical here.

Comment thread src/shell/detection.rs
}
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r"([\w.-]+?)(?:\.exe)?\s+config\s+shell\s+init\b")

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 regex captures only wt from eval "$(git wt config shell init bash)" (and the command git wt … variant) — and is_shell_integration_line_for_uninstall(line, "wt") then rejects it because is_valid_command_position returns false when the candidate is preceded by git . So the git wt … invocation form — explicitly supported in has_init_invocation and exercised by detection.rs's #[case::git_space_wt] / #[case::command_git_wt] tests for cmd = "git-wt" — is no longer matched by the cmd-agnostic uninstall scan. The hyphen form git-wt … (what install actually emits) still works, so this only bites hand-edited rc files, but it's a behavior regression vs. the previous per-cmd uninstall path.

One way to recover it: after is_shell_integration_line_for_uninstall(line, candidate) returns false, if candidate == "wt" and the line text around the match shows a git prefix, retry with "git-wt". A targeted unit test on this helper with the two git wt forms would pin the behavior.

max-sixty added 3 commits May 27, 2026 19:08
# Conflicts:
#	.claude/skills/running-tend/SKILL.md
#	CHANGELOG.md
#	Cargo.lock
#	Cargo.toml
#	docs/content/extending.md
#	docs/content/hook.md
#	docs/content/remove.md
#	skills/worktrunk/reference/extending.md
#	skills/worktrunk/reference/hook.md
#	skills/worktrunk/reference/remove.md
#	src/cli/mod.rs
#	src/commands/config/state.rs
#	src/commands/picker/mod.rs
#	src/commands/step/prune.rs
#	tests/integration_tests/merge.rs
#	tests/integration_tests/remove.rs
#	tests/integration_tests/step_prune.rs
#	tests/snapshots/integration__integration_tests__help__help_remove_long.snap
# Conflicts:
#	src/shell/paths.rs
Close the codecov/patch gap on the uninstall-scan paths added by this branch:
- line_based_config_paths: the Fish/Nushell empty-list arm
- scan_wrapper_directory: wrong-extension, non-file, and unreadable-file skips
- is_shell_integration_line_for_uninstall_any_cmd: the positive match path

Also replace the explicit `Err(e) => return Err(e)` arm in the uninstall
loop with `?` propagation, removing an untestable error arm.
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.

2 participants