Repository navigation
feat(list): publish a JSON Schema for the schema-2 envelope - #3747
Conversation
`wt list --format=json` schema 2 had a derived schema that was never emitted — `test_schema_generates` checked it compiled and threw it away. Generate it from `wt list --print-schema` and commit the result to docs/static/schema/list-v2.json, served at worktrunk.dev/schema/list-v2.json. Two things had to be right for the document to be usable: Generate under schemars' serialize contract, not its default deserialize one. They disagree on `required`: under the deserialize contract a `skip_serializing_if` field is required, because nothing supplies it on the way in. The schema would have rejected the output it documents — every item carrying `default_branch`, `upstream`, `pr`, `checks`, `summary` and `vars` whether or not the absence rule omitted them. Give the three stringly-typed wire fields real enums. `checks.status`, `display.state` and `default_branch.integration.reason` were `&'static str`, so the schema described them as bare strings and dropped their vocabularies. They are now `JsonCheckStatus`, `JsonMainState` and `JsonIntegrationReason`, each converted from its domain enum by an exhaustive match, so a new upstream variant is a compile error rather than a silently unlisted value. Emitted JSON is unchanged. Verified by validating `wt list --format=json`, `--full`, and `list statusline --format=json` against the published document.
Nothing tied the generated schema to the output it describes: schemars derives it from the types without ever seeing an envelope, so the two could disagree and only a reader would notice. Add a `jsonschema` dev-dependency and check the property directly. `schema_document()` moves from `help.rs` to `json_v2.rs`, beside the types it describes, so `--print-schema` and the test compile the same document rather than two constructions that could drift on the contract setting. The battery walks the axes most likely to be got wrong — every `CiStatus` over both sources, every `MainState`, plus the absent and null arms of the absence rule. Confirmed it fails on the bug it exists for: generating under the deserialize contract instead makes it report `pr`, `checks`, `summary`, `vars` and `display.columns` as wrongly required. The dependency is dev-only and pulls no HTTP stack — `reqwest` and `tokio` stay unselected, and `cargo tree --edges normal` finds no path to it.
worktrunk-bot
left a comment
There was a problem hiding this comment.
worktree.operation is a fourth field of the shape this PR converts, and it stays &'static str. JsonWorktreeV2's pub operation: Tri<&'static str> — doc comment In-progress operation: "rebase", "merge", "cherry_pick", "revert", or "bisect" — lands in the published document as {"$ref": "#/$defs/Nullable_string"}, i.e. {"type": ["string", "null"]}, so the schema drops that vocabulary exactly the way checks.status did before this change. The value comes from operation.into() on InProgressOperation's strum::IntoStaticStr, so a sixth variant would appear in published output with nothing failing to compile — the drift the new exhaustive From impls exist to prevent. A JsonOperation mirroring JsonIntegrationReason would close it, and test_operation_arms already pins the five wire names to assert against.
Two smaller ones inline.
Review catch: `worktree.operation` is a fourth field of the shape this branch converts and it stayed `&'static str`, publishing as `Nullable_string` and dropping the `merge`/`rebase`/`cherry_pick`/`revert`/ `bisect` vocabulary the way `checks.status` did. It now goes through `JsonOperation` by exhaustive match, so a sixth `InProgressOperation` variant is a compile error rather than an unlisted published value. The validation battery built every item from `item_with`, which makes a branch row, so `worktree` was absent throughout and the whole subtree — `operation`, both `JsonReason`s, `changes` and its `diff` — never met the schema. Add a worktree row, and assert the battery still reaches it so the gap can't reopen silently. Also fixes a docstring this branch stranded: deleting `test_schema_generates` left its comment on the test below, describing the wrong thing and promising an export this branch ships. Renamed for the helper it actually covers.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The added worktree row only sets git_operation, so the rest of the subtree the fix is named for stays absent-or-null: locked/prunable are None and carry skip_serializing_if, so both fields are omitted, and working_tree_status is None so changes serializes as null. JsonReason, JsonChanges and JsonDiff ship in the published document and still never meet a real object, which makes the guard's message ("battery no longer covers the worktree subtree") claim more than the assertion checks — it pins operation alone. Suggestion inline.
Same class outside the worktree subtree, if it's worth closing in one pass: no battery item sets an integration signal, so check_integration returns None for every row and default_branch.integration lands as Tri::Unknown throughout (the same null test_absence_rule_unresolved_vs_determined asserts for a fresh item). JsonIntegration and JsonIntegrationReason are therefore never validated against output — and JsonIntegrationReason is one of the three enums this PR adds precisely to stop that vocabulary drifting. upstream (item.upstream unset → null) and dev_server (item.url unset → absent) leave JsonUpstream and JsonDevServer in the same position.
Validating an envelope proves nothing about a type the battery never instantiates, and most of the document's types were in that position: the worktree row set only `git_operation`, so `JsonReason`, `JsonChanges` and `JsonDiff` never met a real object, and no row carried integration signals, an upstream, a dev server or a forge — leaving `JsonIntegration`, `JsonIntegrationReason`, `JsonUpstream`, `JsonDevServer`, `GitRepoInfo` and `GitRepoProvider` unexercised too. `JsonIntegrationReason` is one of the enums this branch adds to stop exactly that vocabulary drifting. The previous guard checked `operation` alone while its message claimed the whole subtree, so it would not have caught any of them. Populate the rows, then close the loop rather than the instance: every non-`Nullable_` type in the document is pinned to a path that must carry a non-null value. A new `Json*` type fails until the battery reaches it, and a row that stops populating one fails too. Both directions confirmed by mutation — dropping the forge reports `["GitRepoInfo", "GitRepoProvider"]`, and removing a row from the table reports `["JsonOperation"]`.
…an (#3762) Three developer entry points — `--help-page`, `--help-description`, and `--print-schema` — each carried its own copy of a scan that picked the subcommand out of argv by rejecting entries that looked like the binary: ```rust .filter(|a| *a != "--help-page" && !a.starts_with('-') && !a.ends_with("/wt")) .find(|a| !a.contains("target/") && *a != "wt") ``` Neither `ends_with("/wt")` nor `contains("target/")` matches `wt.exe` under a backslash path, so on every Windows install all three read the binary's own path as the command. `--print-schema` exited 2; the other two exited 0 with empty stdout and `Unknown command: C:\...\wt.exe` on stderr. The scan also took the value of a value-carrying global as the command, so `wt -C <path> list --print-schema` answered `No JSON schema for '<path>'`. clap had already parsed the same argv one call earlier. `parse_early_globals` runs the real `Cli` definition with `ignore_errors(true)` and reads `matches.subcommand()` to pick the alias-splice context, and the comment above it already said that exists "so the splice path in `augment_help` has no separate arg scanner". The doc entry points just never asked it. It now carries the name in an `EarlyGlobals` struct, and the three handlers take `Option<&str>` instead of `&[String]`. Error messages are unchanged: `src/cli/mod.rs` has `#[command(external_subcommand)]` for user aliases, so a name the `Cli` definition doesn't declare still arrives intact and can be named back in `Unknown command: X`. `--print-schema` now prints through `crate::output::print_json` rather than `serde_json::to_string_pretty` plus std's `println!`. std's panics with exit 101 when a consumer closes the pipe; anstream's drops the `BrokenPipe`. This was the last JSON-to-stdout surface still panicking after #3746 — the schema landed via #3747 while that PR was in flight, and it lives outside `src/commands/`, where #3746's stdout guard doesn't scan. <details> <summary>Why CI never caught this</summary> `tests/integration_tests/help.rs` and `tests/integration_tests/readme_sync.rs` are both `#![cfg(not(windows))]`, and on Unix the test binary is always named `wt` under `target/`, which is exactly the shape the scan was written against. The new test drives all four cases through a symlink named `wt.exe` — a symlink rather than a copy because the debug binary is 67MB. Verified to fail against the pre-change code. </details> `wt merge --help-page` and `wt merge --help-description` still panic on a closed pipe. Those use std's `println!`/`print!` deliberately, to preserve the ANSI codes anstream's would strip, so they need a different mechanism than this one. Left for a follow-up. > _This was written by Claude Code on behalf of max-sixty_
Release v0.72.0 — 55 commits since v0.71.0. Minor bump: `cargo semver-checks` reports 5 breaking library changes, so patch is disallowed pre-1.0. ## Headline changes - **`wt merge` / `wt step push` no longer autostash the target worktree** (#3703). Both strategies now advance the target through one `advance_target` — a compare-and-swap `update-ref`, then `read-tree -m -u` in the target worktree — so `refs/stash` is never entered and staged changes stay staged. - **Forge classification returns to brand-in-hostname** (#3673), reverting the exact-DNS-label rule 0.71.0 shipped. `github-enterprise.acme.com` and friends resolve again with no config. - **`[projects."…"]` keys match by `*` pattern and carry forge settings** (#3701), so one user-config entry covers every repository on a self-hosted host. - **A published JSON Schema for `wt list --format=json` schema 2** (#3747), plus machine-readable approval state and `branch_outcome` (#3710). Full detail in `CHANGELOG.md`. ## One fix made during the release cut The release audit surfaced a gap this release's own `advance_target` rewrite introduced, fixed here rather than deferred: **`wt merge` / `wt step push` now refuse a target worktree parked mid-operation.** The target sync is a two-tree merge, which refuses an unmerged index but *not* a stopped cherry-pick or rebase whose conflict has already been staged. A target paused between steps could therefore have the push range written into it, and the user's `--continue` would commit the synced tree as the step's result. The old fast-forward path got this check for free from `receive.denyCurrentBranch=updateInstead`, which refused any unclean target outright; both strategies now ask directly, and the refusal names the worktree holding the operation. `test_push_refuses_target_mid_operation` covers it in both shapes a stopped operation can take, and both are mutation-verified. With the gate disabled, the push succeeds and writes `feature.txt` into the mid-cherry-pick worktree. The rebase case was added in response to review feedback on this PR, and pins a second dependency. A rebase detaches HEAD, so `git worktree list --porcelain` reports the target with no branch and `worktree_for_branch` finds it only because `finalize_worktree` backfills from `rebase-merge/head-name`. That makes the rebase arm the one place this guarantee rests on a helper of ours rather than on git — the fast-forward path it replaced got the refusal from `find_shared_symref`. With the backfill disabled, `wt step push` succeeds against a worktree parked mid-rebase while the cherry-pick case still passes, so the gap was real. ## Validation - Local gate green: `cargo run -- hook pre-merge --yes` — 4570 tests, clippy, fmt, doc sync. - Cross-platform nightly green on the cut-from tip `3817df079` (run 31133551751): full nextest matrix on linux/macOS/Windows, feature-powerset, all three release triples, nix-flake, minimal-versions, unused-deps, crate-build, link-check. - Changelog verified entry-by-entry against the diffs by an independent pass; every one of the 55 commits either maps to an entry or is a documented skip. - `main` advanced during the CI wait. #3762 ships in this release and now has a changelog entry; the other two commits that landed (#3758, #3749) touch only `.github/`. - Data-loss surface reviewed by four independent finders over the cumulative diff. One further finding — a pre-0.72 `approvals.toml` key containing `*` being reinterpreted as a wildcard on upgrade — was reviewed and accepted as out of scope for this release. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk 0.72.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.com`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673)) - **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves) - **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting) - **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests) - **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677)) - **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747)) - **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675)) ### Fixed - **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766)) - **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740)) - **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734)) - **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting) - **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759)) - **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report) - **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600)) - **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762)) - **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727)) - **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719)) - **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692)) - **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656)) - **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731)) - **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687)) - **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688)) ### Documentation - **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709)) - **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706)) ### Internal - **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt See merge request: Harmonybrew/homebrew-core!16275
wt list --format=jsonschema 2 now has a published, machine-readable contract at worktrunk.dev/schema/list-v2.json.The schema was already derived —
test_schema_generatesbuilt one, asserted it compiled, and threw it away, with a comment saying "until the schema export ships." This ships it:wt list --print-schemaprints the document (a developer entry point alongside--help-page, intercepted before clap), and a new step intest_docs_are_in_synccommits it todocs/static/schema/list-v2.json, the same generate-and-commit pattern asllms.txt. It shells out rather than callingschema_for!becauseJsonEnvelopelives in the bin-onlycrate::commandstree.Two things had to be fixed for the document to be usable.
The contract.
schema_for!generates under schemars' deserialize contract, which marks askip_serializing_iffield required — nothing supplies it on the way in. The first document I generated therefore requireddefault_branch,upstream,pr,checks,summaryandvarson every item, all of which the absence rule routinely omits, so it rejected the output it documents. Generating underfor_serialize()fixes it.The vocabularies. Four fields —
checks.status,display.state,default_branch.integration.reasonandworktree.operation— were&'static str, so the schema described them as bare strings. They are nowJsonCheckStatus,JsonMainState,JsonIntegrationReasonandJsonOperation, each converted from its domain enum by an exhaustive match, so a newCiStatus,MainState,IntegrationReasonorInProgressOperationvariant is a compile error rather than a value silently missing from the published vocabulary. The emitted JSON is unchanged; the existing envelope snapshot passes untouched.Before and after, for one item
Testing
test_schema_accepts_envelopesvalidates a battery — everyCiStatusover both sources, everyMainState, a populated worktree row, an integrated row with an upstream and a dev server, plus the absent and null arms of the absence rule — against the same document--print-schemaemits.Validating proves nothing about a type the battery never instantiates, so the test also pins every non-
Nullable_type in the document to a path that must carry a non-null value. A newJson*type fails until the battery reaches it, and a row that stops populating one fails too — the check reports the type names rather than leaving the gap to a reader. This needed ajsonschemadev-dependency: schemars only generates, and derives the document from the types without ever seeing an envelope, so nothing otherwise tied the two together.The test was confirmed to fail on the bug it exists for. Reverting to
for_deserialize()makes it reportpr,checks,summary,varsanddisplay.columnsas wrongly required.The dependency is dev-only:
reqwest,rustlsandasync-traitstay unselected so no HTTP stack comes along, andcargo tree --package worktrunk --edges normal -i jsonschemafinds no path to it.One direction it deliberately does not cover: a loosening. If a field reverted to
&'static strthe schema would saytype: stringand anything would validate. That direction is held by the compiler instead, via the exhaustive matches.Notes for review
schema_document()lives injson_v2.rsbeside the types, not inhelp.rs, so--print-schemaand the test compile the same document rather than two constructions that could drift on the contract setting.worktrunk.dev/schema/follows the entry directly above it: a generated link that 404s until the site deploys.