fix(shell): validate command names and tighten install/uninstall detection#2864
fix(shell): validate command names and tighten install/uninstall detection#2864max-sixty wants to merge 33 commits into
Conversation
`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
left a comment
There was a problem hiding this comment.
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 testsblock at the bottom ofpaths.rscalling one of the three accessors with"wt; touch"and assertingErrorKind::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.
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>
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>
`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
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
| 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.
| } | ||
| 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") |
There was a problem hiding this comment.
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.
# 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.
Two shell-integration correctness fixes plus an uninstall-scope redesign.
Command-name validation. The shell-integration command name comes from the user-typed
--cmdflag, orargv[0]when--cmdis 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 nextinstall. It now detects those forms and reports already-configured.Uninstall scans every worktrunk-managed file, regardless of cmd. Previously
uninstalllocated 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_cmdextracts binary-name candidates from each line and reuses the execution-context check. The--cmdflag is removed fromwt config shell uninstall;installkeeps 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 (
--cmdandargv[0]), and the older-line dedup case.