Skip to content

refactor(hooks): derive the hook preview from the execution path - #3635

Merged
max-sixty merged 2 commits into
mainfrom
hook-preview-shared-context
Jul 28, 2026
Merged

refactor(hooks): derive the hook preview from the execution path#3635
max-sixty merged 2 commits into
mainfrom
hook-preview-shared-context

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

wt hook show --expanded rebuilt the hook pipeline's template context by hand in expand_command_template, duplicating what prepare_steps already does for what actually runs: the same build_hook_context call, the same hook_type and hook_name inserts, the same args default, the same POSIX escape mode. Two copies of one rule, so the preview drifted from the executed command whenever the execution path gained a context key, with nothing to catch it.

The listing now prepares its commands through prepare_steps itself (hook_command_rows in hook_commands.rs) and renders them through render_template_preview, the renderer wt hook <type> --dry-run already used. prepare_steps is the sole producer of hook command contexts, so a key added there reaches both with no second edit.

The args divergence

The old preview inserted args = "[]" unconditionally; prepare_steps defaults it only when unset, because manual wt hook <type> supplies real args upstream via extra_vars. The shared path keeps the conditional default and the listing simply does not supply args. The values coincide (a listing has no CLI args to forward, which is exactly what the default encodes), and keeping the conditional form means the rule stays written once, in the place that has a caller who needs the other branch.

Where the syntax check went

prepare_steps used to reject an unparsable template, so a pipeline that could not render in full never started. A listing wants the opposite: wt hook show is what you run when your hooks are broken, so it annotates the bad template in place and shows the rest.

That check is an execution policy rather than part of building a command, so it moved out of prepare_steps into validate_pipeline_syntax, called by the two funnels every hook-running path goes through: prepare_and_check (foreground, background, dry-run, filtered) and render_planned (the plan-backed hooks behind execute_planned_hook and register_planned). Both are mutation-verified: removing either call fails a test.

A newtype that made forgetting the gate a compile error would be stronger, but it threads a wrapper through SourcedStep, ForegroundStep, and the background pipeline spec for a guard whose failure mode is degraded fail-fast rather than incorrectness (a syntax error still surfaces when its step renders).

User-visible changes

Preview expansion errors now name the hook (Failed to expand project:lint: ...) instead of the generic hook preview.

A template referencing vars.* renders raw in --expanded, matching --dry-run, where before it resolved against git config at preview time. Raw is the honest preview: those values resolve when the step runs, and an earlier step in the pipeline may write them.

Tests

test_hook_show_expanded_matches_dry_run pins the listing and the dry-run to the same rendering of hook_type, hook_name, and args. test_foreground_pipeline_syntax_error_aborts_before_first_step pins the relocated gate; it runs through wt merge's pre-commit hooks because wt hook <type> cannot reach it (the CLI pre-parses every template for shorthand-argument routing and errors first).

Also folded in: adding source: HookSource to the listing renderer left approval_context: Option<(&Approvals, Option<&str>)> encoding the same user-vs-project discriminator, so both sites now call one needs_approval.

This was written by Claude Code on behalf of max

max-sixty and others added 2 commits July 28, 2026 13:28
`wt hook show --expanded` rebuilt the pipeline template context by hand,
duplicating what `prepare_steps` already does. It now prepares its
commands through `prepare_steps` itself, so a context key the execution
path gains reaches the preview with no second edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding `source` to the listing renderer left `approval_context` encoding
the same user-vs-project discriminator, so both sites now call one
`needs_approval`. Also pins the relocated syntax gate with a test that
reaches it — `wt hook <type>` cannot, because the CLI pre-parses every
template for shorthand routing and errors first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty
max-sixty marked this pull request as ready for review July 28, 2026 22:16
@max-sixty
max-sixty merged commit 4888d52 into main Jul 28, 2026
40 checks passed
@max-sixty
max-sixty deleted the hook-preview-shared-context branch July 28, 2026 22:25
max-sixty added a commit that referenced this pull request Jul 28, 2026
Follow-up to #3635, from two reviews that landed after it merged.

## The `vars.*` preview was worse than #3635 claimed

`render_template_preview` short-circuits on
`template_references_var(template, "vars")`, returning the raw template.
So one `vars.` token disabled expansion for the entire command:

```
pre-commit = "deploy --branch={{ branch }} --repo={{ repo }} --env={{ vars.env }}"

before:  deploy --branch={{ branch }} --repo={{ repo }} --env={{ vars.env }}
after:   deploy --branch=main --repo=repo --env={{ vars.env }}
```

#3635 described this as "a `vars.*` template renders raw", which is true
but understates it: `{{ branch }}` and `{{ repo }}` stopped expanding
too, in a command whose whole job is to show the expansion. That
short-circuit predates #3635 and has been degrading `wt hook <type>
--dry-run` the same way; #3635 only extended it to `wt hook show
--expanded`.

The fix is at the source rather than at either caller. A preview now
injects a stand-in object for `vars` that renders each reference back as
itself, nested access included (`{{ vars.config.port }}` round-trips),
while every other variable expands normally. `VarsMode::Resolve` keeps
execution reading real values from git config; only previews pass
`VarsMode::Literal`. A preview also no longer spawns the git read that
resolving `vars` required.

`vars.*` stays literal on purpose: those values are read when the step
runs, after an earlier step in the pipeline may have written them, so a
value resolved at preview time can differ from the one the run uses.

Nothing covered this, which is why the suite stayed green through the
regression. `test_hook_show_expanded_matches_dry_run` now sets a var and
asserts the listing and the dry-run both leave it alone while expanding
`{{ branch }}` beside it.

## The syntax gate is a type error now

#3635 moved the template syntax check out of `prepare_steps` into a free
`validate_pipeline_syntax` that both execution funnels had to remember
to call. `prepare_steps` now returns a `PreparedPipeline` the caller
must resolve: `.validated()` for the paths that run hooks,
`.into_unvalidated()` for the listing, which annotates a broken template
in place rather than blanking itself. Forgetting is a compile error, the
same property `ApprovedHookPlan` gives hook approval.

## Smaller items

Four cross-references went stale when the syntax check moved:
`PreparedCommand.template`, `validate_template_syntax`, the `switch.rs`
skip comment, and `HOOK_INFRASTRUCTURE_VARS` (which still named two
deleted functions). The `--expanded` behavior is now documented in the
sentence that already owns `{{ vars.<key> }}` semantics, with its three
generated mirrors regenerated.

`PreparedStep::commands()` replaces two hand-rolled matches in
`hooks.rs`. `default_branch` moves inside
`build_manual_hook_template_vars` — only the commit-hook arm reads it,
and resolving it can cost a `git ls-remote` on a fresh clone, so the
other eight hook types no longer pay for it. The listing carries its
expansion state in an `Option<String>` instead of re-deriving "was this
expanded?" from whether a context exists.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty pushed a commit that referenced this pull request Aug 7, 2026
…#3765)

Nightly sweep finding: two `.config/clawpatch/features/*.json`
entrypoints name symbols that no longer exist.

**`feat_custom_template_expansion.json`.** `expand_command_template` was
deleted by #3635 ("derive the hook preview from the execution path"),
which folded the preview render into `render_template_preview` in
`src/commands/command_executor.rs` and left `expand_template` in
`src/config/expansion.rs` as the single runtime render. The feature file
kept pointing at the old name, so the one thing it exists to do — send a
reader to the code that renders a project-supplied template before the
approval gate sees it — lands nowhere.

- **Entrypoint** → `src/config/expansion.rs::expand_template`. The
module doc calls it "a single generic function" for template rendering,
and it's what the feature's own summary is about ("expansion happens
before the approval gate sees the final string").
- **`ownedFiles` reordered and re-reasoned.** `expansion.rs` moves first
and its reason now names the render functions. `command_executor.rs` is
added — that's where #3635 put `render_template_preview` and the
foreground step pipeline, so the file the code moved *into* was missing
from a file list that still described where it came from.
`hook_commands.rs` stays, with its reason updated to what it actually
holds now (`run_hook`, the `hook show --expanded` rows).

**`feat_custom_switch_resolve.json`.** Entrypoint `handle_switch` →
`handle_switch_command`. The function was renamed when #3049 moved
switch/remove orchestration out of `main.rs`; the definition today is
`pub fn handle_switch_command` at `src/commands/worktree/switch.rs`.

This second one is why the sweep's original audit reported only one
stale symbol: it grepped each `entrypoints[].symbol` as a plain
substring, and `handle_switch` matches inside `handle_switch_command`
(and `handle_switch_output`, `handle_switch_created_output`, …), so a
renamed symbol whose new name merely extends the old one reads as
present. Re-run anchored on a definition —
`\b(fn|struct|enum|trait|type|const|static|mod)\s+<symbol>\b` inside the
declared `path` — all 14 features now resolve, and no other entrypoint
has this shape.

Following the convention of the last edit to this directory (3554f49,
which fixed `interrupt_exit_code` → `interrupt_signal` in the
signal-handling feature), symbol names are corrected in place and
`updatedAt` is left alone.

No test accompanies this — nothing in the suite reads
`.config/clawpatch/`, which is why both references went stale silently.
A sync test is conceivable, but it would be a new check on a
hand-authored threat-model index whose schema this repo doesn't own; I'd
rather flag the idea than build it unasked. If one is ever added, it
should anchor on the definition, not a substring grep, for the reason
above.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
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