mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
v0.69.2
972 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b623d7f951 |
fix(shell): don't delete user data that only mentions the init command
Three places decided whether text was worktrunk's by testing a blob for
substrings. Two of them deleted the user's data when they guessed wrong.
`wt config shell uninstall` asked two unlinked questions of an rc line —
is an exec keyword anywhere in it, and is `<name> config shell init`
anywhere in it — so `alias setup='echo "run: wt config shell init fish |
source"'` classified as worktrunk's and was removed. One question
replaces both: does the line invoke the init command, with the name in
command position? The exec-keyword scan is gone rather than kept
alongside; it read English words out of the whole line and rejected
nothing the positional rule doesn't.
`wt config shell install` had the same defect one file over and one step
worse: `is_worktrunk_managed_content` tested the whole file for
`config shell init` and `| source`, and `cleanup_legacy_fish_conf_d`
deleted a user's own `conf.d/wt.fish` outright — during an install, which
never offered to remove anything. The cmd-agnostic twin beside it already
answered correctly, so the substring version is gone.
Uninstall now prints every line it takes, in the confirmation and again
after removal, since shell quoting stays undecidable without running the
line.
Sweeping for the same shape found it in `az` failure classification,
where `stderr.contains("login")` alone meant "not authenticated" and
`bail!` discarded the real error; `az` states the remedy itself, so its
text now reaches the user. Provider host matching moved from
`contains("github")` to label-boundary comparison, so
`dev.azure.com.attacker.example` and `mygithubmirror.com` no longer
resolve to Azure DevOps and GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b17a1364f1 |
docs(step): render wt step rebase & wt step push on the docs site (#3578)
Follow-up from #3568, which named these two as the one plain oversight
in its audit of `after_long_help` bodies that never reach a docs page.
`wt step rebase` and `wt step push` were the only two of twelve step
operations with no `<!-- subdoc: -->` marker, so their help was
terminal-only, and the only two bullets in `## Operations` left
unlinked. Both markers are added between `squash` and `diff`, per the
ordering invariant in `src/cli/step.rs`.
Both openers restated their `about` line, which reads as immediate
self-repetition on the web where `combine_command_docs()` concatenates
the two. Both bodies are rewritten.
## What reviewing the text against the code turned up
The old rebase body said "Conflicts abort immediately; use `git rebase
--abort` to recover", which is self-contradictory and wrong. Nothing
aborts: the worktree is left mid-rebase with git's conflict markers, and
git's output names `--continue`, `--skip`, and `--abort`. `wt merge`'s
pipeline step 3 carried the same sentence.
Four review passes then falsified nine more claims, every one reproduced
against a scratch repo before editing:
- **`--no-ff` runs no `git push` at all.** It builds the merge with
`commit-tree` + `update-ref` and syncs the worktree with `read-tree`; a
`-vv` trace shows zero `git push` invocations. The opener attributed the
whole command to `git push` with `--no-ff` as an example four lines
below. Its worktree sync is also best-effort, so the ref can move while
the worktree stays behind.
- **The Outcomes table was falsified by the upstream-swap topology.**
With local `main` diverged and behind `origin/main`, `wt step rebase
main` prints `Already up to date with origin/main` — `main` is not an
ancestor of the branch, and the result names a ref never typed.
- **"Nothing here reaches a remote" was false on a fresh clone.** With
no target argument and no cached `worktrunk.default-branch`,
`default_branch()` falls through to `git ls-remote`. The claim is now
"no commits leave the repository", which holds unconditionally.
- **The generated Arguments table contradicted the prose.** `[TARGET]
Target branch` sat two paragraphs below "the target is any commit-ish";
a blind reader nearly concluded rebase needs a branch name.
- **The table implied mutually exclusive rows.** `is_rebased_onto` is
checked first, and a branch at the target's tip satisfies both of the
first two.
- Plus: an orphan branch matched no row; "git's own output names the
ways out" is false under `advice.mergeConflict false`; "refused rather
than forced" raised the force question without closing it.
Adjacent, in files this change already touched: `wt merge`'s step 3
contradicted the new table and step 5 restated `wt step push`'s rule
with no link, so half the pipeline deferred and half repeated. Step 2
promised a backup ref unconditionally, but `create_safety_backup` runs
only when working-tree changes were staged — a clean-tree squash
rewrites commits and writes no `refs/wt-backup/` ref. That is a
data-safety claim, so it is corrected rather than left.
`docs/CLAUDE.md`'s subdoc "Use cases" cited `wt config create`, which
has no marker and no section.
## The code changes
**An annotated-tag target was never peeled.** Adding a tag example to
the rebase help is what made it worth running, and it did not work.
`is_rebased_onto` compared `git merge-base`, which peels an annotated
tag to its commit, against a bare `rev-parse`, which returns the tag
object's SHA. Those never match, so an annotated-tag target always
looked like it needed a rebase:
```
annotated v1.2.0 → "outcome": "rebased" (HEAD unchanged)
lightweight v1.2.0-lw → "outcome": "up_to_date" (same graph, same commit)
```
Fixed at the root by peeling with `^{commit}`, rather than documenting
the quirk. The test pairs the two tag types on one commit so the control
is a single factor away; it fails without the fix.
The reviewer caught that this test had gone missing, and it was right
about the consequence — without it, removing the `^{commit}` peel passes
CI. The cause was a merge resolution on this branch: `checkout --theirs`
on `tests/integration_tests/merge.rs` took main's whole file, dropping
`test_step_rebase_annotated_tag_is_peeled` along with the duplicate
squash test it was meant to drop. Restored, and re-verified in both
directions.
**`wt step push` ran out of a half-finished operation.** Mid-rebase, the
detached HEAD is a linear extension of the target, so the fast-forward
check passed and `wt step push main` printed `✓ Pushed to main (1
commit)` — moving the target branch onto a half-replayed history while
the worktree kept its conflict markers and the rebase stayed open. It
now runs the `ensure_no_operation_in_progress` gate `wt step rebase` and
`wt merge` have used since
|
||
|
|
f5d99d4fe5 |
fix(step): refuse to commit unresolved conflicts; one gutter symbol for every operation (#3579)
Two follow-ups from #3558, which stopped `wt merge` and `wt step rebase` from running out of a half-finished operation. Probing the sibling commands for the same root cause found one that was worse, and the symbol work is what that PR's detection change made possible. ## `wt step squash` committed conflict markers Mid-merge, invoked directly, it didn't refuse. It generated a commit message for the unresolved markers and committed them: ```console $ git merge side CONFLICT (content): Merge conflict in f $ wt step squash --yes ◎ Generating commit message and committing changes... (1 file, +6, no squashing needed) Merge branch 'side' This resolves a merge conflict between main and side branches. ✓ Committed changes @ e6262c6 $ git show HEAD:f <<<<<<< HEAD main ||||||| 56e6eb3 base ======= side >>>>>>> side ``` `MERGE_HEAD` is gone and the working tree is clean, so the broken merge reads as complete. `wt merge` was never exposed — its own gate stops it before it reaches squash — so this was the direct-invocation path only. ## The index is what knows The obvious fix is the gate #3558 added, and it is not sufficient. The hazard is narrower than "an operation is open", and also wider. `git add -A` collapses an unmerged path's three index stages into one entry. That resolves the conflict as far as the index is concerned, while `<<<<<<<` is still on disk — and it takes with it git's own refusal to commit an unmerged index, which is what would otherwise have stopped this. So the exposure is exactly the commands that stage on the user's behalf, and it does not need an operation to be open at all: ```console $ git stash pop CONFLICT (content): Merge conflict in f $ ls .git/MERGE_HEAD ls: .git/MERGE_HEAD: No such file or directory $ wt step commit --yes ✓ Committed changes @ 1f5387c # markers and all ``` A conflicted `git stash pop` leaves unmerged paths with no state file written, so no reading of `.git/` can see it. `WorkingTree::ensure_no_unmerged_paths` reads the index instead — `git diff --diff-filter=U`, the same question `git commit` asks — and both staging paths call it before staging: ```console $ wt step commit ✗ Cannot commit: 1 path with unresolved conflicts ┃ f ``` The paths are worth carrying where the operation refusal carried nothing: they are the one thing the user needs and git isn't being asked. `wt step squash` additionally takes the operation gate, ahead of its branch check, so mid-rebase it names the open rebase rather than blaming the detached HEAD and offering `git switch <branch>` — the one command that throws the rebase away, which is the same wrong remedy #3558 removed from `wt merge`. Both guards run before the pre-commit hooks and the LLM call. Neither is worth running for a commit that can't happen, and a refused commit runs no project commands — checked with a `pre-commit = "touch HOOK_RAN"` project config that never fires. `wt step commit --dry-run` is deliberately not gated. It mutates nothing, and guarding it displaced `test_step_commit_dry_run_propagates_git_add_failure`, which pins a distinct error path; a tested behavior is worth more than cosmetic parity. ## One symbol for every operation The Status column had `⤴` for rebase and `⤵` for merge, and nothing for the other three states git can leave open. A worktree stopped mid-cherry-pick, mid-revert, or mid-bisect rendered as idle — including the case #3558 closed, a multi-commit cherry-pick whose stop was resolved with `git commit`, which leaves a clean tree, no `CHERRY_PICK_HEAD`, and only the queued sequencer to say anything is wrong. Three more glyphs was the obvious fix and the wrong one. What the reader does about any of the five is identical: run `git status`, then finish or abort it. Splitting the column across a glyph per operation asked them to distinguish states that lead to the same next step, and the split is what left the other three invisible. So they collapse to one: ```console $ wt list Branch Status … Message @ main ↻^ … resolved by hand ``` `git status` names which operation it is, in git's own words — the same division of labor as the refusal message. `--format=json` keeps the identity the symbol drops, so a consumer that needs it still has it: `operation_state` gains `cherry_pick`, `revert`, and `bisect` alongside `rebase` and `merge`. That retires `ActiveGitOperation`, which existed only to re-encode `InProgressOperation` down to the two states the gutter knew. `WorktreeData.git_operation` is now `Option<Option<InProgressOperation>>` — outer `None` is "not loaded" — matching its neighbour `has_working_tree_conflicts`. `GitOperationTask` also stops swallowing errors: it reports a failed probe through the same `ctx.error` channel every other task uses, rather than reporting "no operation" for a probe that didn't answer. ## Verification Three integration tests, each asserting HEAD is unmoved rather than only matching the message: - `test_step_squash_refuses_mid_merge` — the case that committed markers. - `test_step_commit_refuses_unmerged_paths` — driven from a conflicted `git stash pop`, so it can only pass through the index read; the operation check cannot see that state. - `test_list_shows_symbol_for_bisect` — bisect because it is the only operation that leaves HEAD on the branch and the tree clean, so nothing else in the Status cell stands in for it. Snapshots pin both the symbol and `"operation_state": "bisect"`. Confirmed by hand against real repos: mid-merge, mid-rebase, and the conflicted-stash-pop state all refuse; a mid-merge commit whose conflicts *are* resolved still succeeds, as does a clean squash; a queued cherry-pick and a stopped revert both render `↻` and name themselves in JSON. Full gate green (4500/4500 tests, lints, fmt, doc sync), plus `cargo test --features shell-integration-tests` (2148/2148) and `cargo clippy --all-targets --features shell-integration-tests`, which the gate doesn't compile. > _This was written by Claude Code on behalf of Maximilian_ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f9cd38eb88 |
fix(shell): validate command names and tighten install/uninstall detection (#2864)
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`; 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). A headerless file
(the legacy `conf.d/{cmd}.fish`, which held nothing but the init line)
qualifies only when every non-blank, non-comment line is an integration
line, so a user's own file that runs or merely mentions `wt config shell
init` amid other code survives the scan. Fish completion files carry
their own `# worktrunk completions for` header and are scanned the same
way, so a completion whose wrapper is already gone is still cleaned up.
For Bash/Zsh/PowerShell (line-based),
`is_shell_integration_line_for_uninstall_any_cmd` reads the binary name
off the line rather than pattern-matching it. `config shell init` is a
fixed literal, so the name is the run of command-name characters ending
at it, held to the same `validate_shell_command_name` rule that governs
what worktrunk writes into shell code. The execution-context check that
pairs with it is a line-level property, so it lifts out of the
per-position loop it used to sit in, which is what let both cmd-agnostic
detectors drop their regexes. Since the result asks only whether a line
is worktrunk's, not which install owns it, uninstall also removes
hand-written `git wt config shell init` lines, and it cleans every
matching profile file rather than the first (both PowerShell profiles on
Windows). All detection reads only the code portion of a line: a
trailing `#` comment can mention an integration line without running it,
so uninstall does not delete an unrelated `eval`/`source` line over a
comment, and install does not treat such a mention as
already-configured. The cmd-specific permissive detector lost its last
caller in this cutover and is deleted.
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). The FAQ's "What can Worktrunk delete?" inventory documents
the widened uninstall surface.
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, a hand-written `git wt` line, a stale second
wrapper, and an orphaned completion), a PowerShell uninstall round-trip
on the old pre-`Out-String` line, malformed-name rejection at the CLI
edge (`--cmd` and `argv[0]`), and the older-line dedup case. Unit tests
pin the deletion criterion: worktrunk's own wrappers and the headerless
legacy init file match; a file that mentions the integration, or runs it
amid other code, does not.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Worktrunk Bot <w@worktrunk.dev>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
|
||
|
|
4549715af1 |
docs(claude-code): link the statusline segment details instead of restating them (#3576)
The `## Statusline` section of `claude-code.md` paraphrased three facts that the `wt list statusline` reference already owns: | Fact | `claude-code.md` said | `src/cli/list.rs` says | |---|---|---| | Pace maths | "1.4× the pace that would exactly fill that window, coloured by how much of it would be spent locked out at the cap" | "2.9× the pace that would exactly fill that window… colour deepens with severity as the forecast lockout grows" | | Link degradation | "clickable links where the terminal supports them, plain text where it doesn't" | "Both links are OSC 8, which a terminal that doesn't support them discards" | | CI fetch latency | "runs it in the background… 1–2 second CI fetch invisible" | "reach the network for a second or two, so it fits a statusline the host renders in the background" | #3568 removed the near-verbatim copies from this section but left these paraphrases behind, which is arguably the worse state: two wordings of one fact drift apart independently, and a reader can't tell whether they're the same claim or two different ones. ## What stays The cut isn't to zero. The page shows an example line, so it needs enough gloss for a reader to parse the line in front of them — a bare pointer would make the section a stub. It keeps one sentence naming what the stdin JSON contributes, with "how the links behave" folded into the pointer that was already there. The latency clause also stays, though it is the third duplicated fact. Without it the opening paragraph only restates the command name, and it is the one "why" that justifies the `settings.json` block below it. ## Side effect This leaves the `OSC 8` wording in `src/cli/list.rs` as the single home for the link behaviour. A precise term earns its place in an exhaustive `--help` reference and grated on an end-user integration page — so the jargon was really a symptom of the fact living in two places. Docs-only. Mirrors regenerated by `test_docs_are_in_sync`; `zola build` clean (14 pages, anchors resolve). > _This was written by Claude Code on behalf of Maximilian Roos_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11a1498fa1 |
docs(list): render wt list statusline's help on the docs site (#3568)
`wt list statusline`'s `after_long_help` documents the three output formats, the Claude Code stdin JSON contract, and the pace segment, but `wt list`'s help had no `<!-- subdoc: statusline -->` placeholder, so none of it reached `docs/content/list.md` or the skill reference. It was terminal-only. Adding the placeholder pulls it in as a `wt list statusline` section under `# Subcommands`, matching how `wt step` and `wt config` expose theirs. ## What reading it on the web surfaced The help text needed edits once it rendered as a page rather than a block below an Options list. A context-blind agent was given the two rendered pages and asked to answer setup questions from them alone; three of its snags were real. **The format lists were stale and used private names.** They read `branch status ±working commits upstream ci url`, while the Columns table higher up the same page calls those `HEAD±`, `main↕`, `Remote⇅`. They also omitted `main…±`, which `format_statusline_segments` has emitted since that column became a default. The lists now use the column names and include it, and `claude-code` is stated as a delta on `table` rather than repeating it. **The formats read as a fixed layout.** The example line on the Claude Code page has nine segments against an eleven-name format string, with no explanation. Three rules were in the code and in no doc: empty cells are omitted, `claude-code` drops `branch` when `dir` already ends in `.<branch>` (`filter_redundant_branch`), and an overlong line drops whole cells worst-priority-first (`fit_to_width`). All three are now stated. **The latency caveat lived only on the Claude Code page.** A reader of the command's own docs had no way to learn it reaches the network. It moves to the reference. That exposed a contradiction with the definition, "Single-line status for shell prompts", against a caveat saying it is too slow for a synchronous prompt — so the definition becomes "Single-line status for the current worktree". This is the one user-visible string change here; it lands in `wt list --help` and the three help snapshots. ## Deduplication with the Claude Code page The pace paragraph and the OSC 8 paragraph were near-verbatim on both pages, and would have rendered twice on the site. `claude-code.md` keeps what is Claude Code-side (install, demo, the example line, and a plain note that the links degrade to unclickable text where the terminal lacks support) and links to the reference for the rest. ## Follow-ups folded in The module docstring at `src/commands/statusline.rs:4` carried `±working commits upstream`, the last occurrence in the tree of the labels this branch retired, and opened "Statusline output for shell prompts" — the framing the definition dropped. Both now match the help text. ## Not done An audit of every `after_long_help` in `src/cli/` found 46 that never reach a docs page. Most are editorial calls rather than oversights: `wt config create` alone would embed ~450 lines of example TOML into a page that already covers that ground by hand. The one that looks like a plain oversight is `wt step rebase` / `wt step push`, the only two of twelve step operations without a marker, and the only two the `## Operations` list leaves unlinked. Covering them needs their openers rewritten first, since both currently restate their definition. Left for a follow-up. Verified with `wt hook pre-merge --yes` (4499 tests) and a `zola build`, which checks internal anchors. > _This was written by Claude Code on behalf of Maximilian Roos_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f3e5c82604 |
feat(statusline): dim the dev-server URL until its port answers (#3561)
Follow-ups to #3550. The dev-server URL in the statusline dims until something answers on its port, matching the `wt list` cell it was already copying in every other respect. ``` ~/w/worktrunk.feature ↕|🤖 ↑2 ↓1 #3550 :17913 server up ~/w/worktrunk ⊂| ↑2 ↓1 #3550 :12107 server down (dim) ``` `url_active` was already collected on this path (the health check runs under the statusline's full-column plan), so the dim consumes something already paid for rather than adding a probe. ## The other half, and why it isn't here The follow-up list also carried "detect OSC 8 support and render accordingly", which is how `wt list` decides its URL cell: linked `:3000` when the terminal supports hyperlinks, the URL in full when it doesn't. I built that, and three review passes took it apart. Recording the path, because the conclusion is the interesting part: 1. The first version probed `stderr`, since a shell prompt captures stdout through command substitution, and forced links on for `--format=claude-code`. An adversarial pass showed the exception reintroduced the bug it was meant to fix: Claude Code re-emits OSC 8 to the terminal rather than drawing it, so Claude Code inside Apple Terminal got the link, and the URL collapsed to a bare `:3000` with its host inside an escape the terminal discarded. 2. Reading the environment alone fixed that and deleted the exception. Then a bug sweep measured what the plain-text fallback actually costs: unlinked, the cell grows from 5 columns to the whole URL, and the URL is the worst-priority segment, so `fit_to_width` drops it first. Against a long dev hostname the linked line kept the URL down to 39 columns and the unlinked one lost it below 82. At 80 columns in tmux the "fallback" showed nothing where the old code showed a clickable port. 3. Separating the two decisions fixed that (collapse to `:port` always, link only when the terminal renders it) but left the probe controlling nothing but escape bytes. At which point an outer-loop pass asked whether it should exist, and it shouldn't: - The [OSC 8 spec](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) guarantees a terminal implementing OSC parsing per ECMA-48 "is guaranteed not to suffer from compatibility issues … the target URI is silently ignored and the supposed-to-be-visible text is displayed, without artifacts." The named buggy set is VTE up to 0.48 (2018), Windows Terminal up to 0.9, Emacs term-mode, and screen with 700+ character URLs. The statusline has emitted unconditional OSC 8 to shell prompts since #199 in January, on that same reasoning. - `supports-hyperlinks` is an allowlist, so its dominant error is the false negative. xterm, foot, mintty, rio, contour and a configured tmux all render OSC 8 and none are named. The probe's certain cost is withdrawing a working link from those users, against a speculative and shrinking risk. - The escape-hatch argument I had for it was inert. Once the probe no longer changed any text, `FORCE_HYPERLINK=0` could not reproduce what the old hardcoded Claude Code gate did, which was print the URL in full and copyable during that regression window. So the answer to "shouldn't we detect?" is that `wt list` detects because there the answer changes what the reader gets: `estimate_url_width` reserves a column wide enough for the full URL, so an unlinked cell can print it. The statusline reserves nothing, so it has no second rendering to switch to. `format_url_cell` now says that, in place of a stale note claiming the statusline's stdout was a pipe "to its editor". ## Also here - `isolate_subprocess_env` scrubs `FORCE_HYPERLINK`. It stands alone: `wt list` probes with `on(stream)`, which is `(FORCE_HYPERLINK set || tty) && allowlist`, and tests pin `TERM=alacritty`, so a developer with `FORCE_HYPERLINK=1` exported flips the `wt list` table snapshots from full URL to linked `:port`. - The `table` and `claude-code` format lists in `wt list statusline --help` name the `url` segment, which they emit and never listed. - A `--format=claude-code` test pins where the URL segment lands among the directory, CI and model segments. ## Verification Full gate green (`cargo run -- hook pre-merge --yes`, 4486 tests). Beyond the suite, the built binary rendering a live and a dead port, and the drop threshold measured at six widths to confirm the segment behaves as before. ## Known, not addressed A `[list] url` with no port is permanently dim on both surfaces: `UrlStatusTask` only connects when a port parses, so `url_active` stays `None`, and the `== Some(true)` test reads "never checked" as "nothing listening". Pre-existing in `render.rs`, which this change doesn't touch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9ecd7945e0 |
fix(merge): measure squash/rebase against the target's upstream, not a stale local ref (#3549)
Fixes #3519. ## The bug `wt merge` resolves its target to the **local** default-branch ref and squashes/rebases the span `merge-base(local-target, HEAD)..HEAD`. When the primary checkout's local `main` is behind `origin/main` **and** the branch descends from the newer `origin/main` tip (e.g. created with `--base origin/main`), that merge-base falls back at the stale local tip, so the span sweeps in every commit already on `origin/main`. The squash folds them into a single commit and the local fast-forward lands it — corrupting the primary checkout's `main` and, if that ref is later pushed, duplicating already-upstream content under new SHAs. Reproduced deterministically before fixing: a 1-commit feature off `origin/main`, with local `main` two commits behind, squashed "3 commits" into a new SHA on local `main` — matching the report. The same mis-measured span reaches every rewrite entry point: a standalone `wt step squash` folds the upstream commits into the *branch* (the reporter's 1-commit PR becoming 13), and `wt step rebase` replays them onto the stale ref when it fires. ## The fix: measure against the upstream; let the merge carry One local-only predicate (no fetch — worktrunk is local-first), `Repository::span_upstream`: the target's upstream, returned exactly when the branch's history extends past the local target ref into it — i.e. `merge-base(HEAD, upstream)` is not an ancestor of the local target. The rewrite pipeline responds by *measuring* there instead of guessing from the stale ref: - **`wt step squash`** (and the squash inside `wt merge`) computes its span, commit count, message, and `reset --soft` base against the upstream — only the branch's own commits fold, and the target ref is never touched. The `--dry-run`/`--show-prompt` previews use the same base. - **`wt step rebase`** (and the rebase inside `wt merge`) rebases *onto* the upstream, so a non-linear branch replays only its own commits instead of duplicating upstream ones onto the stale ref. In the plain stale topology it no-ops, as before. - **`wt merge`** announces the situation up front, then completes: the fast-forward carries local `main` through the already-fetched upstream commits by their real SHAs to the result — the same graph a fresh local `main` would have produced: ``` ○ Local main is 2 commits behind origin/main; the merge fast-forwards through them ◎ Squashing 2 commits into a single commit (2 files, +2)... ✓ Squashed @ 3f2a91c ✓ Merged to main (3 commits, 3 files, +3) ``` The one refusal left is forced by topology rather than chosen by policy: a target that has **diverged** from its upstream (its own commits *and* behind, with the branch based past it) can never fast-forward in any mode — reconciling the target's own commits is the user's call, so the merge stops before any rewrite or approval prompt: ``` ✗ Local main has diverged from origin/main — can't fast-forward to a branch based on origin/main ↳ Reconcile main with origin/main (rebase or merge its local commits), or specify a different target ``` This is the reporter's suggested direction ("use `origin/<default>` as the merge/rebase target"), minus the network: the already-fetched remote-tracking ref is the measurement base, and `wt merge` still never touches the wire. It also extends the rule the rest of worktrunk already applies — `IntegrationTargets` / the preview panes' comparison base treat the upstream as the truth when the local default lags it; the merge pipeline was the one mutating surface that didn't. Quiet on everything that must keep working: a legitimately-diverged local target with the branch forked from the shared base (the supported offline workflow — existing regression test unchanged), targets with no upstream, orphan branches, and explicit non-branch targets like `wt step squash origin/main`. ## Tests - `test_merge_carries_stale_local_main_through_upstream` — the #3519 topology end-to-end: merge succeeds, the carry is announced, the squash folds only the branch's own commits and sits directly on the `origin/main` tip, and `origin/main` is an ancestor of the merged `main` (real SHAs, no duplication). - `test_step_squash_measures_against_upstream_when_local_main_stale` — the standalone squash folds only the branch's own commits and never moves the target ref (this scenario previously corrupted `main` through a squash-then-merge sequence). - `test_step_rebase_targets_upstream_when_local_main_stale` — a branch that merged `origin/main` in rebases onto the upstream: linearized, real upstream SHAs retained, target ref untouched. - `test_merge_refuses_diverged_target_when_branch_based_on_upstream` — the forced refusal: nothing mutated, branch survives. - `test_merge_removes_branch_when_local_main_diverged_from_upstream` (legitimate divergence, branch from shared base) still passes — no regression. Docs: the merge help page (primary source in `src/cli/mod.rs`) describes the upstream-aware measurement, the carry, and the diverged refusal; generated mirrors and help snapshots regenerated. > _This was written by Claude Code on behalf of Maximilian_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
14d4d92532 |
feat(statusline): make the CI and URL segments clickable in Claude Code (#3550)
The statusline suppressed OSC 8 hyperlinks in Claude Code mode, so its CI segment was colored but not clickable and its dev-server URL printed in full. Claude Code renders OSC 8, so both segments now link the way they already do in `wt list`. ``` ~/w/worktrunk.statusline-osc8-hyperlinks ↕|🤖 ↑2 ↓1 ^+93 -24 #3550 :17913 Opus 4.8 └link┘ └link┘ ``` ## Why it was off The gate dated from 2026-01-02, inside Claude Code's OSC 8 regression window — links worked through 2.0.76, broke around 2.1.3, and got a partial IDE-terminal-only fix in 2.1.42 ([anthropics/claude-code#26356](https://github.com/anthropics/claude-code/issues/26356)). The premise was correct when written and has since expired; that issue was auto-closed for inactivity rather than on a fix, so the tracker understates current support. ## Verification PTY-captured the raw byte stream from Claude Code 2.1.218, with `TERM_PROGRAM`/`VSCODE_*` stripped so it exercises the standalone-terminal path that was reported broken. Claude Code doesn't strip or blindly pass through — it parses OSC 8 into its frame model, assigns a hyperlink id, and re-emits canonically (normalizing an ST terminator to BEL): ``` PRE ESC]8;id=1l7bqdh;https://example.com/ST-PROBE BEL STLINKTEXT ESC]8;; BEL MID … ``` It holds in both the normal and alt-screen (`CLAUDE_CODE_NO_FLICKER=1`) render paths, with `FORCE_HYPERLINK` unset. Driving the real `wt` binary through Claude Code end to end yields both links live: ``` LINK: https://github.com/max-sixty/worktrunk/pull/3550 LINK: http://127.0.0.1:17913 ``` Degradation is graceful: a terminal or multiplexer that drops OSC 8 shows the same text, just not clickable (tmux only gained OSC 8 in 3.4; zellij and Alacritty support it). ## Shape of the change With links unconditional for the statusline, the plumbed `include_links` flag had one value, so it collapses into the segment builder. `format_url_cell` likewise takes the link decision from its caller rather than probing the terminal, matching `PrStatus::format_cell` — the statusline's stdout is a pipe, so `supports_hyperlinks` reports false there even though the consumer renders OSC 8. That left `hyperlink_stdout` with no callers, so it goes. `format_cell` keeps its `include_link` parameter: `wt list` passes the terminal probe, and the picker passes `false` because a `--prs` row never reaches the strip path. `format_url_cell` moved next to `estimate_url_width`, which budgets the column against it — the two have to agree on when a cell collapses to `:port` and were in separate files. The URL segment also gets shorter: the URL rides inside the escape sequence, so `http://127.0.0.1:17913` becomes `:17913`, returning 16 columns on a line that budgets by width. ## Safety of the truncation interaction `truncate_visible` ends its cut with `\e[0m`, which resets colour but leaves an OSC 8 link *open* — a severed link would make the rest of the terminal line clickable, and `ansi_cut` really will sever one if reached. It can't be reached, because the two cuts never meet: `fit_to_width` drops whole segments worst-priority-first and stops at one, so character truncation only ever lands on a best-priority survivor — Directory (0), Branch or Model (1) — none of which carry escapes beyond SGR. Every link-bearing segment is strictly worse (CI 5, URL 9), so each is dropped entire first. Reviewing the branch turned up that the numbered comments in `format_statusline_segments` had drifted from `COLUMN_SPECS` — CI was labelled 9 (it is 5) and the URL 8 (it is 9), with branch-diff and upstream also off — and the first version of the test had taken those stale numbers as its specification. The comments are corrected and the test now rests on the invariant above, which doesn't depend on where CI sits. It sweeps widths 1–90 over both links, asserts it spans every drop stage, and pins that the URL goes before CI. A second test pins that the hidden URL costs no visible width (`ansi_strip` drops OSC 8 for both terminators), so priority budgeting isn't inflated. ## Docs The Claude Code statusline page now says the segments are clickable — it's the feature's own page and said nothing about it. The `wt list --help` JSON field description gains the links but stays short: an earlier, longer wording shrank the help table's Field column and wrapped two dozen unrelated rows. ## One judgement call worth flagging `format_statusline_segments` also feeds plain `wt list statusline` (shell prompts) and the JSON `statusline` field. The CI link was already unconditional on both before this change; what's new is that the URL cell renders as a linked `:3000` rather than the full URL, so a consumer that strips OSC 8 sees only `:3000`. The structured `url` / `dev_server.url` field still carries the full URL, and `:3000` still answers "which port", so this reads as the right trade — but it is the one place the collapse to a constant reaches a renderer that isn't Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c6eb148e62 | docs(hook): correct pre-switch {{ branch }} resolution note (#3516) | ||
|
|
d44d68797a | docs(extending): add workz to custom-subcommand examples (#3513) | ||
|
|
79be81a4bb | fix(merge): Accept merge-shaped fast-forward histories (#3509) | ||
|
|
85c9241cb1 |
Name the template owner in the -v variables block (#3495)
## The question
When `wt` shows a `template variables:` block under `-v`, does it say
which template the variables belong to? Mostly no — and in one case it
genuinely can't be worked out from the header.
## What was there
Four sites print a variables listing, and none named its owner in the
header:
| Site | Header | How you'd tell what it's for |
|---|---|---|
| foreground hook (`command_executor.rs`) | `template variables:` | the
`hook_type` row at the bottom of the table, or the `Running …` line
below it |
| background hook (`hooks.rs`) | `template variables:` | same — but see
below |
| alias (`alias.rs`) | `template variables:` | only the `Running alias
greet` line below it |
| `wt step eval` (`eval.rs`) | `Available template variables` |
unambiguous (one template, named on the command line) |
The adjacent blocks in the same `-v` lane already label themselves — `○
eval source`, `○ user:noop result` — so the variables table was the odd
one out in its own family.
Background hooks turned that from inconvenient into ambiguous. `wt -v
remove <branch>` prints one table per hook type back to back, then a
*single* combined `Running post-remove: cleanup (user); post-switch:
notify (user)` line — two identical headers over two ~20-row tables that
differ only in their rows.
## The change
Prefix each header with the hook type, alias name, or `eval`.
```
- ○ template variables: ○ post-remove template variables:
- ○ template variables: ○ post-switch template variables:
```
`wt step eval -v` now reads as one labeled family:
```
○ eval template variables:
branch = feature/auth-oauth2
○ eval source
{{ branch }}
○ eval result
feature/auth-oauth2
```
`eval`'s header was reworded from `Available template variables` for the
same shape; it was never ambiguous, but it's the fourth member of the
family and now matches the `source` / `result` headers directly beneath
it.
Display-only — no logic changed. Snapshots and the generated `step.md`
pages regenerated; `cargo run -- hook pre-merge --yes` green (4461
tests).
> _This was written by Claude Code on behalf of Maximilian Roos_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
568b6de85f |
docs: consolidate duplicated explanations and trim slop (#3494)
Sweep of the docs for repetition and filler, from an audit of the hand-authored pages, the command pages' source in `src/cli/mod.rs`, and the plugin skill. Net −574 lines; every cut either had a surviving canonical home or restated an adjacent sentence. **One home per mechanism** (other mentions now link to it): - `template-append` — the LLM commits guide owns the rendering mechanism; the user- and project-config sections keep the key, an example, and what is unique to them (the project approval gate and the only-`template-append`-from-project scoping). Was explained in full in three places. - `-vv` diagnostic files — `wt config state logs` owns the four file descriptions; the FAQ summarizes in one sentence and links. - fsmonitor/trash cleanup — the FAQ's two overlapping `wt remove` bullets merge into one that distinguishes own-daemon teardown from the orphan sweep; troubleshooting.md's restatement compresses to a pointer, keeping its unique wedged-daemon-on-live-worktree guidance. - copy-ignored built-in excludes — the `wt step copy-ignored` page owns the directory list (previously enumerated 4×); the config sections state the rule and link. - hook-types table — `wt hook` owns it; the extending guide replaces its verbatim copy with a sentence. - LLM tool commands — unchanged, deliberately: the apparent hand-synced duplication between llm-commits.md and the config example is already machine-pinned by `test_llm_docs_commands_match_config_example`, so it cannot drift. **Cut-over debt**: the deprecated `wt config state ci-status` section shrinks to a deprecation pointer — its status table, fetch order, and caching notes all duplicated the `wt list` CI-status section. **Structure**: `wt step copy-ignored`'s "Features" list dissolves into the sections that owned its facts (excludes → "What gets copied", reflink → "Performance"); the four trailing "Note: This command is experimental…" lines go (the `[experimental]` badge already appears twice per section); shell-integration.md described the directive-file mechanism twice and now describes it once. **SKILL.md** drops from 339 to 181 lines: the permission-model section duplicated the config-types bullets, the hook-type mapping appeared twice, and the "Determining Which Config to Use" / "Validation Before Adding Commands" scaffolding enumerated judgments an agent makes on its own. The approvals-escalation and agent-handoff sections are untouched. **Deliberately not addressed**: `wt list`'s schema 1 JSON tables (still the default schema; the wholesale deletion lands when the default flips) and corpus-wide em-dash density (a house-style decision, not a per-page fix). Generated mirrors (docs command pages, skill references, plugins mirror, `dev/*.example.toml`, help snapshots) regenerated via `test_docs_are_in_sync` and `cargo insta`. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
d80cc80ebd | feat(config): flag stale default-branch cache in wt config state (#3471) (#3478) | ||
|
|
bb4abca109 |
fix: anchor relative WORKTRUNK_PROJECT_CONFIG_PATH to the worktree root (#3460)
`WORKTRUNK_PROJECT_CONFIG_PATH` was returned verbatim, so a relative value resolved against the process cwd while the default `.config/wt.toml` is anchored to the worktree root. Running `wt` from a subdirectory made a relative override silently no-op (or read an unintended file). This is the config-path footgun from #3454 (the RFC there is a separate discussion). A relative override now resolves against the same worktree root as the default (current worktree, or the primary worktree at a bare root), so it behaves identically from any subdirectory. Values that can't be anchored fail loudly instead of guessing: - relative with no worktree root to anchor to (bare repo with no linked worktrees) errors - Windows forms that are neither fully absolute nor relative (drive-relative `C:cfg`, rooted-but-driveless `\cfg` or `/tmp/x`) error rather than resolving against the process drive or cwd Absolute values are unchanged (returned verbatim, no git calls), which keeps the test-isolation use of the var working. An empty value still means no project config, as before. `wt config show` (human format) and `wt config update` previously folded a `project_config_path()` error into "not in a git repository" / a silent skip; they now propagate it, matching the JSON path. Merging main brought in #3462's committed default-branch config fallback for bare repos whose primary worktree is parked off the default branch. That fallback now stands down when `WORKTRUNK_PROJECT_CONFIG_PATH` is set: an explicit override names the config source outright (a missing file there means no project config), and the override exists for test isolation, where reading the repo's own committed config is exactly the leak being prevented. The human format of `wt config show` now reports "No project config" when no location exists (bare repo with no worktrees, empty override) instead of the inaccurate "Not in a git repository", which stays reserved for actually being outside a repo. Compatibility: invoked from the worktree root, cwd and root coincide, so old and new resolution agree. From a subdirectory the old resolution found nothing, which is the bug being fixed, so no deprecation shim is included. Thanks @indexzero for reporting in #3454. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d12c55a320 |
feat(config): wt config update adopts json-schema = 2 instead of pinning 1 (#3436)
Flips the direction of the `[list] json-schema` pending-default handling that #3411 introduced: `wt config update` now writes `json-schema = 2` (adopting the upcoming default) instead of pinning `= 1` (preserving the current one). Running update is the migration; staying on schema 1 is the deliberate manual edit. The `wt list --format=json` nag flips to match, keeping the adopt action last for easy copying: ``` ▲ JSON output is schema 1; a future release switches the default to schema 2 ↳ To keep this format set [list] json-schema = 1; to adopt the new schema, run wt config update ``` Why: with pin-to-1, every `wt config update` run during the deprecation window entrenched users on the schema being retired, leaving a pinned cohort the default flip could never migrate. With adopt-2, update moves users forward as a reviewed config edit, and after the flip `= 2` is just a redundant default a future rule can strip. The trade: `wt config update --yes` in a script switches JSON output as a side effect of unrelated migrations (the interactive path shows the diff first). The system-config gate is unchanged — when the system layer defines the key, update leaves the user file alone; the test now covers the sharper direction (system `= 1` must not be overridden by a user-file `= 2`). All detection/warning invariants carry over; the diff is ~6 semantic lines plus pin→adopt wording and 65 one-line snapshot flips. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e02453aa5f |
feat(list): demote branch-worktree mismatch from red flag to dim info (#3419)
Implements option 3 ("demote the presentation") from the design proposal
in #3415, for #3389: worktrees created by agent harnesses at their own
conventional paths (`.claude/worktrees/…`) made every `wt list` row
carry a red `⚑` at merge-conflict severity.
The list predicate, JSON fields, and Path-column behavior are unchanged;
only the presentation changes:
- `⚑` renders dim yellow (was red) in `wt list`, the picker, and the
`--help` legend: below the full-yellow actionable states (prunable,
locked), above plain dim.
- Priority within the status slot becomes `✘ > ⤴ > ⤵ > ⊟ > ⊞ > ⚑ > /`:
the yellow actionable states (prunable, locked) now outrank the
informational glyph instead of being masked by it. The JSON schema-1
`state` field follows the same priority; schema-2 booleans are
independent and unaffected.
- The inline notice on `wt switch` / `wt remove` / `wt merge` / `wt step
prune` is gone entirely: the `wt list` glyph is the state's one surface.
The `expected_path` plumbing is deleted end to end, so switching to an
existing worktree no longer expands the `worktree-path` template at all
(it only ran to feed the notice).
Before/after on a repo with agent worktrees:
```
+ claude/frosty-kilby-92c7d3 ⚑_ (red ⚑, reads as an error)
+ claude/frosty-kilby-92c7d3 ⚑_ (dim-yellow ⚑, reads as a note)
```
Option 1 from the design doc (narrowing the predicate so only genuine
collisions flag at all) can land separately on top of this.
Well-tested: the pre-merge gate passes (4390 tests), snapshots reviewed
line-by-line (glyph color and priority changes in list/help output; the
notice lines removed from switch/remove output), the removed-notice
tests renamed to pin the silence, and clippy is clean including
`--features shell-integration-tests`. Verified live: `wt list` shows the
dim-yellow flag; `wt switch`/`wt remove` print nothing about the
mismatch.
Thanks to @dmsmidt for the report in #3389.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c532d2dabd | docs(llm-commits): bump Codex commit-generation model to gpt-5.6-luna (#3430) | ||
|
|
0b42d42670 | feat(step promote): add --format text|json flag (parity with rebase/push/etc.) (#3424) | ||
|
|
bd504f0998 | feat(switch): run --execute against the picked worktree (#3394) | ||
|
|
dac0f80608 |
feat(config): wt config update pins [list] json-schema while unset (#3411)
Wires the `[list] json-schema` nag into the standard config-update machinery, so the warning comes with the standard one-command fix. `wt config update` now pins `json-schema = 1` (the behavior-preserving choice) when the key is unset, via a new `PendingDefault` variant in `DEPRECATION_RULES`: applied on the update pass only — the load path must not pin, or an in-memory `Some(1)` would silence the nag — and scoped to user config through a `ConfigFileKind` enum threaded through detection in place of the old string labels. Two guards keep the pin honest: it stays inert when the system config layer already defines the key (a user-file pin would override a system-level `= 2` and flip resolved output), and the nag's hint offers `wt config update` only when the same detection update runs would actually write the pin — a missing, unreadable, or malformed user config falls back to naming the manual setting. The detection-equals-migration invariant holds with the warning relocated: the pin's warning fires at the JSON-emitting surface (`resolve_json_schema`) exactly when update would change the file, while config load stays quiet (`DeprecationKind::is_pending_default` filters it, and pin-only configs skip the warning-dedup machinery entirely), so `wt switch` users never see it. `wt config show` renders the pending pin's diff — including for empty config files — but keeps its TOML dump: a pin is additive, unlike a deprecation diff that supersedes the dump. This departs from the plan reviewed in `design/list-json-v2.md` (#3357), which deferred the `wt config update` integration to the default flip. Deliberate tradeoff: users who run update during the window land pinned on schema 1 and will see the `= 1` deprecation round after the flip; in exchange, the warning ships with its fixer. **Testing:** unit tests pin the rule's iff (unset ⟺ update changes the file), kind scoping (System/Project inert, load pass inert), the PendingDefault-rule/kind coupling, and placement in existing or implicit `[list]` sections; integration tests cover both hint variants, the update flow end-to-end (pin applied, second run clean), `--print`, and the system-config deferral. The invariant battery runs with an explicit pin appended so each case exercises only its own rule. Full pre-merge gate green (4386 tests) plus `--features shell-integration-tests` clippy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5e58827a28 |
bench(prune): rust-scale prune fixture, criterion benches, and removal trace spans (#3401)
Makes `wt step prune` / `wt remove` staging performance measurable and reproducible. Users report prune taking many seconds while the synthetic benches scan in ~150 ms; this adds the observability and fixtures that reproduce the gap and attribute it. No `wt` runtime behavior changes — the `src/` diff is ~29 lines of trace spans. ## What a reviewer needs - **Trace spans** (`src/commands/step/prune.rs`, `src/commands/process.rs`, `src/git/fsmonitor.rs`): `prune-gather` / `prune-scan` / `prune-check:<ref>` / `prune-remove:<label>` plus `internal-sweep` around `wt remove`'s end-of-command janitor. A `prune-remove` span starts before the write-lock acquisition, so it reads as "how long this removal stalled the run" (documented in benches/CLAUDE.md). - **Fixtures** (`tests/helpers/wt-perf/src/lib.rs`, the bulk of the diff): `prune-M-U` — M squash-merged candidate pairs (content-integrated, the post-PR-squash shape) against a two-sided-diverged backdrop forked across history; `prune-real[-M-U]` — the same shape on a rust-lang/rust clone, default 12+24 → 36 linked worktrees. The real fixture is cache-managed under `target/bench-repos/` (~15 GiB, minutes to build): `ensure_prune_real_repo` classifies it Intact/Consumed/Broken on reuse, re-creates candidates consumed by a live prune in ~1 min (round derived from the surviving squash commits, no sidecar state), and heals invalidated worktree indexes. - **Benchmarks** (`benches/prune.rs`): criterion groups `prune_e2e` (dry-run cold/warm + live with per-iteration candidate re-creation) and `prune_real_repo` (warm dry-run only). The real group is gated behind a new `real-repo-benches` cargo feature so the nightly benchmarks workflow — plain `cargo bench` on a hosted runner — never builds a fixture bigger than the runner's disk and the actions cache cap. ## Measured (M-series Mac, in benches/CLAUDE.md) Live prune on the rust-scale fixture: **~12 s wall** — a 5.4 s stat-cold scan (36 `git status` at ~4.5 s each, absorbed by the rayon pool) plus 24 removals serialized under the scan write lock (~0.5–1.7 s per worktree candidate). Warm re-scan: 0.25–0.8 s. This reproduces the reported "prune takes seconds" experience and points optimization at status cost and removal overlap, not the integration probes. Rider fix picked up en route: `docs.anthropic.com/en/docs/build-with-claude/claude-code` now 404s (failing lychee on every push, including on main), so the llm-commits installation link and the `wt config plugins` install-hint now point at `code.claude.com/docs/en/setup`. Also fixes a latent fixture bug: `history_spread_shas` divided by its hardcoded 5000-commit cap, so on short synthetic histories every "history-spread" fork silently collapsed to the tip. ## Testing wt-perf unit tests cover the fixture lifecycle (state classification, derived repair rounds, squash content-integration); every `ensure_prune_real_repo` path (build, cached, repair, index-heal, foreign cwd) was exercised end-to-end locally, as were both criterion groups and the one-shot timelines. An integration test asserts the `wt remove -vv` trace surfaces the fsmonitor sweep (span plus daemon count), pinning the sweep's observability contract. The rust-scale numbers are I/O-bound and ambient-load-sensitive — documented as shape, not thresholds. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b2cbc6a8c |
docs(llm-commits): fix 404 Claude Code docs link (#3399)
## Problem The `lint` CI job (its `lychee-system` link-check step) fails on the default branch: ``` [404] https://docs.anthropic.com/en/docs/build-with-claude/claude-code | Rejected status code: 404 Not Found | Followed 2 redirects: --[301]--> platform.claude.com/docs/en/docs/build-with-claude/claude-code --[307]--> platform.claude.com/docs/en/build-with-claude/claude-code ``` Anthropic migrated its docs and the old `build-with-claude/claude-code` path now redirects to a dead `platform.claude.com` URL. The link was green when it last ran on `main`; the upstream migration broke it since, so the next push to the default branch fails `lint`. ## Solution Point the link at `https://docs.anthropic.com/en/docs/claude-code/overview` (verified `200`), which is the same URL form already used elsewhere in the repo. The link lives in `docs/content/llm-commits.md` (the primary source for this non-command doc); `skills/worktrunk/reference/llm-commits.md` is the generated mirror, regenerated via `test_docs_are_in_sync`. ## Testing - `curl -sIL https://docs.anthropic.com/en/docs/claude-code/overview` → `200`. - `cargo test --test integration test_docs_are_in_sync` passes (source and skill mirror in sync). - No `build-with-claude/claude-code` occurrences remain under `docs/` or `skills/`. --- Separated from the [PTY-flake CI fix (#3398)](https://github.com/max-sixty/worktrunk/pull/3398) to keep each concern atomic. Surfaced while fixing [run 29053905721](https://github.com/max-sixty/worktrunk/actions/runs/29053905721). Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
f593438e7c |
feat(remove): add experimental --reap to kill worktree processes (#3396)
Implements the design agreed in #3365: an opt-in, experimental flag on `wt remove` that reaps processes left running in the worktree. ## The name `--reap` — it's the term the whole thread and the issue title already use, it's concise (matching worktrunk's flag style), and the user-facing messages read naturally (`◎ Reaping 2 processes under feature worktree`). ## What it does ```console $ wt remove --reap feature ◎ Reaping 2 processes under feature worktree ┃ 51234 node ┃ 51240 esbuild ✓ Reaped 2 processes ◎ Removing feature worktree & branch in background (same commit as main, _) ``` Processes are discovered by working directory (`lsof -d cwd`): any process whose cwd is at or under the worktree path. Termination reuses the existing `SIGTERM`→wait→`SIGKILL` escalation (`escalate_terminate`, shared with the fsmonitor sweep). ## Data-safety posture Killing a process the user didn't mean to kill — a terminal editor with unsaved buffers — is exactly the silent loss-of-work the project refuses without consent, so two guards keep `--reap` conservative: - **Controlling-terminal exclusion.** A process holding a controlling terminal (an interactive shell, or `vim`/`nvim`/`emacs -nw`) is never reaped (`ps -o tty=`). Only detached processes — the dev servers and watchers this issue is about — remain candidates. This also spares the shell `wt remove` was run from. - **Self-exclusion.** The current `wt` process is never a candidate. The flag itself is the explicit opt-in; the list is printed before signalling for transparency. ## Scope / limitations (matching the #3365 discussion) - **Under-inclusive by design.** cwd discovery misses a daemon that forked and `chdir`'d away, or one that reparented to `init` — they no longer report a cwd under the path. Those are what [`wt step tether`](https://worktrunk.dev/step/#wt-step-tether) is built to reap (whole process group). `--reap` and `tether` cover different gaps and are complementary, not substitutes — the docs say so. - **Ordering.** Reaping runs before the worktree directory is staged/renamed (cwd matching needs the directory in place), so it's independent of foreground/background removal, trash-vs-delete, and `--force`. - **Unix only.** Windows has no cheap per-process cwd; `--reap` is rejected there with a clear error. ## Tests - Pure parsers for `lsof`/`ps` output (`parse_lsof_cwd`, `parse_ps_tty`). - End-to-end against the real `lsof`/`ps`: spawns a child with a cwd under a tempdir, asserts `processes_under` discovers it, asserts the controlling-terminal guard keeps-or-drops it in agreement with the child's *actual* TTY state (so the test is host-independent — CI has no TTY, a dev box does), then reaps it and confirms `SIGTERM`. - CLI snapshot (`test_remove_reap_no_processes`) covering the no-candidates path, deterministic whether or not `lsof` is installed on the runner. Help text, `docs/content/remove.md`, and the skill reference mirror are regenerated and in sync. One thing worth a maintainer's eye: I chose to print-then-signal rather than add an interactive confirm/`--dry-run` in this first cut — the opt-in flag + TTY exclusion + printed list felt like enough for an experimental flag, and a confirm step is easy to layer on if you'd prefer it. Closes #3365. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
29fa13dc95 |
docs(code-signing): add SignPath Foundation code-signing policy page (#3366)
Addresses the first, gating step of option #1 (SignPath Foundation) from #3355 — the fix for the `Trojan:Win32/Wacatac.B!ml` false positive on the unsigned Windows binary. ## What this PR does Adds a **code signing policy** page at [worktrunk.dev/code-signing/](https://worktrunk.dev/code-signing/). A published policy page is a hard eligibility requirement for the [SignPath Foundation](https://signpath.org/terms.html) free OSS code-signing program, so it's the natural thing to land first — the application can't proceed without it. The page documents: - **Certificate provenance** — the required attribution ("Free code signing provided by SignPath.io, certificate by SignPath Foundation") and the HSM-held key. - **What's signed** — `git-wt.exe` in the Windows release archive / winget; nothing else. - **The build+sign pipeline** — tag-triggered, cargo-dist on GitHub runners, SignPath action for signing, all reproducible from public source. - **Project roles** (Author / Reviewer / Approver), **per-release manual approval**, and the **no-telemetry** privacy stance SignPath asks projects to state. The generated skill mirror (`skills/worktrunk/reference/code-signing.md`) and `docs/static/llms.txt` entry are produced by `test_docs_are_in_sync` — not hand-edited. > **Roster check:** the roles table lists @max-sixty as Author/Reviewer/Approver. Adjust if anyone else should hold signing authority before this is submitted to SignPath. ## What this PR deliberately leaves out The `release.yaml` signing job and the SignPath account are a **follow-up**, for two reasons: 1. **The concrete slugs don't exist yet.** The SignPath action needs `organization-id`, `project-slug`, and `signing-policy-slug` — all issued *after* the project is registered and approved. 2. **cargo-dist doesn't sign natively, and the wiring is checksum-sensitive.** cargo-dist generates a per-artifact `.sha256` in the *local build job*, alongside the `.zip`. Signing the binary afterward means the signed `.zip` no longer matches its checksum, so a correct job has to sign → re-zip → **regenerate the `.sha256`** → overwrite the artifact before the `host` job uploads it. I don't want to ship that against a live release pipeline without being able to run it end-to-end (it only runs on a version-tag push), so it belongs in a follow-up gated behind the real SignPath config. I've laid out the exact maintainer steps and the proposed job shape in a comment on #3355. ## Verification - `cargo test --test integration test_docs_are_in_sync` — green (regenerates + validates the skill mirror, llms.txt, and Zola link transformation). - zola build + lychee link check run in CI (`check-docs`); neither tool is installed in the tend sandbox. Closes nothing on its own — #3355 stays open until signing is live. ## Follow-up commits on this branch - **`docs/static/code-signing.md` companion symlink** → `skills/worktrunk/reference/code-signing.md`, matching every existing page, so the `worktrunk.dev/code-signing.md` entry in `llms.txt` resolves once deployed. Also swaps a dead `opensource.axo.dev/cargo-dist` link for the repo's `axodotdev.github.io/cargo-dist` convention. - **`.config/lychee.toml` exclusion** for the self-referential `worktrunk.dev/<page>.md` links in `llms.txt`. Those point at the live site, so a brand-new page's entry always 404s in the link check until the site is deployed — a bootstrap gap every new-docs-page PR hits. Their existence is already guaranteed by `test_docs_are_in_sync`, and prose/template links use the trailing-slash form (`worktrunk.dev/faq/`), so the exclusion is scoped to the `llms.txt` index entries only. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com> |
||
|
|
512a94de2f | feat(plugin): ship Codex-native activity hooks (#3364) | ||
|
|
93edf90922 | Correct Windows guidance on system settings (#3372) | ||
|
|
9edf0aa6b3 |
feat(approvals): add wt config approvals list and clear --stale (#3380)
## What
Two additions to `wt config approvals`:
- **`list`** — shows every command the project config declares (hooks in
lifecycle order, aliases, commit-message guidance) grouped into
**APPROVED** and **UNAPPROVED** sections, using `wt hook show`'s symbol
vocabulary (`○` approved, `❯` requires approval). Approvals recorded for
commands no longer in the config (edited or removed since approval) are
listed in a separate warning block. Works without a project config
(recorded approvals still list, all stale). Output pages like `wt hook
show`.
- **`clear --stale`** — removes only those left-behind approvals,
echoing each removed command; valid approvals survive. Staleness is
recomputed under the approvals-file lock (`Approvals::revoke_stale`), so
an approval recorded concurrently by another process is never removed.
Conflicts with `--global` (staleness is defined by the current repo's
config). A missing project config is an error, matching `add` —
approvals are keyed repo-wide while the config resolves per-worktree, so
a branch that merely lacks the file must not classify everything as
stale and wipe the repo's approvals; intentional wipe-everything remains
plain `clear`.
The `list` stale-block hint points at `clear --stale` instead of the
all-clearing `clear`.
## Example
```
APPROVED
○ pre-merge:
┃ cargo test
UNAPPROVED
❯ post-start dev:
┃ npm run dev
▲ Approved commands no longer in project config:
┃ some removed command
↳ To clear stale approvals, run wt config approvals clear --stale
```
## Notes
- The approval-prompt rendering and `approvals add` share the new
`ApprovableCommand::{label,format_template}` helpers and
`collect_approvable_commands`, so the prompt, `add`, and `list` render
commands identically.
- `list` is strictly read-only over approval state; nothing here runs a
project command.
- Staleness matching normalizes deprecated template variables on both
sides, same as `is_command_approved`.
- Also documents the ✓-vs-○ rule (completed action vs state) in the
`writing-user-outputs` skill.
## Testing
Unit tests for `stale_approvals`/`revoke_stale` (normalization both
directions, no-op, project-entry removal); integration snapshots for
`list` (mixed, all-approved, stale-only, no-config) and `clear --stale`
(removal + follow-up list, nothing-stale, `--global` conflict, and
no-config erroring with the approvals file proven intact). Full
pre-merge gate green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
56e20a4a78 |
feat(list): add JSON schema 2 behind [list] json-schema (#3357)
Adds a second JSON schema for `wt list --format=json` and `wt list
statusline --format=json`, selected by a new `[list] json-schema` config
key. Schema 2 is an envelope (`schema`, `repo.default_branch`,
`repo.forge`, `collected`) over items carrying independent facts; schema
1 (the current bare array) is byte-identical to today and remains the
default. Unset emits schema 1 plus a once-per-process stderr nag naming
both settings; `= 1` pins silently; an invalid value warns and degrades
like any other config problem. The nag is suppressed on the statusline
surface, which would otherwise corrupt prompts.
**The semantic core is the absence rule**: absent = nothing to report
(not applicable, not requested per `collected`, or determined-empty),
null = requested but undetermined (probe pending, timed out, fetch
failed). Three mechanisms keep it honest — `integration` derives from
the same committed-content signals `wt remove` trusts
(`check_integration`) rather than the cleanliness-gated display
collapse; skip-seeded conservative defaults are recorded on
`ListItem.seeded` and serialize as null instead of masquerading as
determined facts (invariant-pinned: no seed can fabricate a positive
integration match); and orphan sentinel counts are guarded so they can't
read as a same-commit match.
**For reviewers, in reading order**: `src/commands/list/json_v2.rs` (the
serializer and its `Tri` tri-state), `src/commands/list/mod.rs`
(`resolve_json_schema` + wiring), `src/commands/statusline.rs`
(`run_json`), and small model/collect extensions (`SeededFacts`,
`Collected`, `UpstreamStatus.upstream_short`, task-plan union so a
listed `ci` column forces the fetch for JSON like the table).
**Design doc**: the full rationale, field-by-field mapping, and
migration plan were reviewed as `design/list-json-v2.md`, which rode
this branch as its first commit. Design docs are review-only by repo
convention, so a final commit removes it from the net diff; it remains
readable at
|
||
|
|
67d2177029 | docs(hook): split cwd exception bullet to match 'three cases' count (#3367) | ||
|
|
e74b4241bf |
Consolidate -vv notifications onto diagnostic.md, led by the performance profile (#3329)
`-vv` runs write log files to `.git/wt/logs/`. Previously the stderr output was noisy — a 5-path startup gutter, then the performance profile and the diagnostic bundle each announced separately at exit, with `profile.txt` / `diagnostic.md` named twice. This consolidates it. ## What changed **One doc to read.** `diagnostic.md` is now the single human-facing report, and it leads with the performance profile (`<details open>`, promoted above the environment / worktree / config dumps) instead of burying it fifth. The standalone `profile.txt` is removed — nothing read it (`wt config state logs profile` re-renders live from `trace.jsonl`), and its content already lives in the bundle. **Two stderr lines, both `○`.** A `-vv` run now opens with a one-line pointer to the log directory and closes by naming what it captured: ``` ○ Verbose logging to ~/…/.git/wt/logs/ …command runs… ○ Logs, performance profile, and diagnostics saved @ ~/…/.git/wt/logs/diagnostic.md ~/…/.git/wt/logs/trace.jsonl ~/…/.git/wt/logs/subprocess.log ↳ To report a bug, create a secret gist with gh gist create --web ~/…/diagnostic.md and reference it from an issue at https://… ``` The two files listed beneath are the raw companions the bundle doesn't inline — `trace.jsonl` (machine source) and `subprocess.log` (uncapped bodies). `trace.log` (inlined, truncated) and the profile (inlined verbatim) are omitted to avoid double-listing. **More rows.** The profile now reports the 20 slowest calls (was 8) and 10 same-context redundant-command offenders (was 3). ## Testing Integration tests in `tests/integration_tests/diagnostic.rs` cover both stderr blocks, the file set written at `-vv`, and that `diagnostic.md` leads with the profile. Verified against real `wt -vv list` output; full pre-merge gate green (4301 tests). > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f045e0f449 |
perf(list): cut warm-cache wt list re-run ~36% by killing redundant per-row git forks (#3334)
## What
A warm re-run of `wt list` (where `.git/wt/cache/` is already populated)
is throughput / critical-path bound on per-worktree and per-branch git
subprocesses. Two of worktrunk's three cache layers — `Arc<RepoCache>`
and the process-global path/merge-base/commit-tree `DashMap`s — live in
process memory and don't survive across invocations, so every re-run
re-forks work that's content-addressed or already in hand. The "~1 ms
recompute, not worth a disk file" assumption behind keeping merge-base
and commit→tree in memory is false at scale on macOS (10–25 ms per fork
× dozens of rows).
This eliminates three classes of those forks. On a 24-worktree /
120-branch mixed-state fixture, subprocess work drops **3629 ms → 1013
ms**; the criterion `rerun_warm` benchmark goes **212 → 135 ms median**
(−36%, p<0.05). What remains is the irreducible working-tree floor —
per-worktree `git status`, and `add -A`/`diff`/`write-tree` for dirty
worktrees — which depends on live working-tree state and genuinely can't
be cached.
## The three changes
1. **`%T` primes the commit→tree cache** (`diff.rs`). The pre-skeleton
`git log --no-walk` batch already resolves each commit for `%ct`; adding
`%T` rides along for free and primes `commit_tree`, so the per-row
`CommittedTreesMatch` / `WouldMergeAdd` tree lookups never fork `git
rev-parse <sha>^{tree}`.
2. **Persist merge-base** (`sha_cache.rs`, `diff.rs`). New
content-addressed `merge-base/` sha_cache kind (in-memory front over
disk back) plus a `sha1 == sha2` short-circuit. The `AheadBehind` orphan
check forked `git merge-base` per row even when ahead/behind counts were
already cache-warm; now it's a file read on re-runs.
3. **Prime worktree root/git-dir** (`mod.rs`, `collect/mod.rs`). `git
worktree list` already gives every worktree's path; seeding
`WORKTREE_ROOTS`/`GIT_DIRS` from it (git-dir read from each `.git`
entry, mirroring `prewarm_rev_parse`'s canonicalization) eliminates the
per-worktree `git rev-parse --show-toplevel` / `--git-dir` forks. Runs
post-skeleton — its values are consumed only by the worker pool, so it
stays off the skeleton critical path and is skipped under
`WORKTRUNK_SKELETON_ONLY` (measured: skeleton time unchanged at 24.4
ms).
All three are behavior-preserving: caches are content-addressed and
never stale, and any value that can't be derived cleanly falls back to
the original subprocess.
## Benchmark
Adds `cargo bench --bench list rerun_warm` (24 worktrees + 120 branches
in varied states: clean / unstaged / staged+untracked worktrees; merged
/ ahead / diverged / identical branches), a reusable
`wt_perf::create_mixed_repo` fixture, and a `wt-perf setup mixed-W-B`
config. The existing list benches are warm-capable but each cover
worktrees-only or branches-only in a single uniform state. A
`TODO(bench-full-combined)` marks consolidating these into one cold+warm
"full" scenario with per-feature trace attribution.
## Reviewer orientation
- Cache mechanics: `src/git/repository/sha_cache.rs` (new kind) and the
`# Caching` docstring in `src/git/repository/mod.rs` (updated to
document the in-memory-front-over-disk-back decision and correct the
stale cost premise).
- The list-collect path: `src/commands/list/collect/mod.rs` module
docstring (caching table + `%T` fork doc updated).
- Tests: cache priming, merge-base roundtrip (incl. orphan), and a
prime-vs-subprocess equivalence test that pins the derived git-dir
against the real `git rev-parse` across main + linked worktrees.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4388defff5 |
feat(list): add git.branch.* template namespace for custom columns (#3319)
Adds a `{{ git.branch.* }}` template namespace for `wt list` custom
columns, parallel to `{{ vars.* }}`. It surfaces a branch's own git
config under `branch.<name>.*` — both convention keys you set yourself
(`branch.<name>.jira`) and the git-native `branch.<name>.description` —
without re-storing the values through `wt config state vars set`. This
is the gap left open in #3258: `vars.*` only reads worktrunk's
`worktrunk.state.<branch>.vars.*` namespace, so keys a user already
keeps in git config were invisible to the list.
## Before / after
With `branch.feature.jira` and `branch.feature.description` already in
`.git/config`:
```toml
[list.custom-columns.Jira]
template = "{{ git.branch.jira }}"
[list.custom-columns.Summary]
template = "{{ git.branch.description | lines | first }}"
```
```
Branch … Jira Summary
feature … HWINFCI-2810 Add telemetry instrumentation
main … ← no branch.main.*, empty cells
```
Previously the only way to populate these columns was to re-enter the
data via `wt config state vars set`; now the branch's own config is read
directly.
## Design
- A new `git` top-level namespace (rather than a flat `branch_config`)
so it can grow other git-derived per-branch fields later
(`git.upstream`, `git.remote`, …) without claiming a new top-level name
each time. Today it holds `git.branch.*`.
- `git.branch.<key>` maps 1:1 to `git config branch.<name>.<key>`. Note
git lowercases config variable names, so `branch.<name>.nvciShelf` reads
as `{{ git.branch.nvcishelf }}`; the git-native `description` is
multi-line, so `| lines | first` gives the summary line.
- Data comes from the existing in-memory bulk config snapshot — one
read, zero subprocesses per cell, on the same skeleton-first path as
`vars`. The reader shares a `subsection_map_from_snapshot(parse)` helper
with the existing `all_vars_from_snapshot`.
- Parsing splits `branch.<name>.<key>` with `rsplit_once('.')`, which is
correct because git variable names can't contain dots: dotted/slashed
branch names (`feature.foo`, `feature/bar`) keep their full subsection,
and git's section-level keys (`branch.sort`, `branch.autoSetupMerge`)
flatten to two segments and are skipped.
- Scoped to list custom columns only — no leakage into
hook/alias/pipeline template contexts.
## Testing
Unit tests for the parser/reader (dotted + slashed branch names,
variable-name lowercasing, `branch.sort` skip) and for rendering
(including `description | lines | first`); a JSON integration test
exercises the full `wt list` path end-to-end. Verified manually against
a scratch repo. Full pre-merge gate green (4297 tests, clippy, fmt,
rustdoc, docs-in-sync).
Closes #3258. Thanks to @cazador481 for the request.
> _This was written by Claude Code on behalf of max_
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
fec7edc72c |
feat(list): force a listed column on past the --full preset gate (#3295)
## Problem
A `[list] columns` entry could narrow the table but never *force a
column on* past its gate. Listing `ci` without `--full` still hid the CI
column; the gate won. But `--full` is a column-selection preset — it
bundles CI/summaries into the default table — not a feature-approval
switch, so "I listed `ci`" being silently overridden by "you didn't pass
`--full`" is surprising.
## Solution
A listed column now overrides the **preset** gates while still
respecting the **data-source** gates. The distinction is a new
`ColumnSource` (`Default` | `Listed`) threaded through the task planner:
- **Preset gates** — `--full` (`show_full`), `[list] summary`
(`summary_enabled`) — bundle columns into the *default* table. A
`Listed` column overrides them: listing `ci` shows it without `--full`;
listing `summary` shows it whenever an LLM command exists.
- **Data-source gates** — `[commit.generation]` (`has_llm_command`),
`[list] url` (`has_url_template`) — are hard prerequisites. They apply
even to a `Listed` column: a listed `summary` with no LLM command, or
`url` with no template, stays hidden, because listing can't conjure data
that isn't configured.
`Default`-source behavior (the default table, the picker, and `--format
json`, which all plan over `all_columns`) is byte-for-byte unchanged —
`column_renders(kind, Default, gates)` reduces to the old function.
### Before / after
`columns = ["branch", "ci"]`, no `--full`:
```
before: Branch # ci dropped — gated by --full
after: Branch CI # listed → forced on
```
### Picker consistency
The picker plans tasks from `all_columns` (it needs every column's data
for its preview tabs) but renders the `[list] columns` selection. So it
now plans the **union** of its preview set and the selection's forced-on
columns, keeping its table identical to `wt list`'s. This matters for
one case: a listed `summary` with an LLM command configured but `[list]
summary` off — `wt list` shows it, and without the union the picker
would have hidden it.
## Testing
- **Unit** (`test_required_tasks_for_render`): both sources × every gate
combination — listed-`ci`-without-`--full`, listed-`summary` overriding
the presets, listed-`summary` still needing an LLM command, listed-`url`
still needing a template, and `Default`-source parity.
- **Integration**
(`test_list_config_listed_column_overrides_full_gate`): end-to-end —
`columns = ["branch", "ci"]` renders the `CI` header with no `--full`,
where the default set hides it. Asserts on column enrolment (the
rendered header), independent of whether `gh` is on `PATH`.
- **Layout**: the filter test was repurposed
(`test_layout_renders_column_iff_task_planned`) to test "render iff task
planned" in isolation, since the planner no longer produces the old
"listed but unplanned `ci`" pairing.
- Full `pre-merge` gate (all suites, fmt, clippy, docs-sync, PTY picker
snapshots) green after merging `main`. The picker's 44 PTY tests pass
with no snapshot churn.
## Docs
The `[list] columns` help prose was rewritten ("Listing a column forces
it on, space permitting…") in the `src/cli/mod.rs` source and
regenerated across all four render contexts (terminal `--help`,
`config.example.toml`, `docs/content/config.md`, skills reference). The
`CLAUDE.md` network-access inventory also now notes that a listed `ci`
column reaches the wire without `--full`.
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c1c76b150d |
refactor(trace): decouple human trace.log from machine trace.jsonl (#3297)
At `-vv`, worktrunk's `trace.log` carried machine-parseable `[wt-trace] ts=… tid=… seq=… cmd="git status" dur_us=12300 ok=true` lines *alongside* a `$ git status [ctx]` start echo — so every command appeared twice in two inconsistent renderings, and the human file was cluttered with `key=value` machine fields. The root cause: `trace.log` was doing double duty as both the human artifact and the machine-parsed source, even though `trace.jsonl` (added in #3232) already carries the same fields losslessly and nothing read it as input. This decouples the two. `trace.log` + stderr become purely human; `trace.jsonl` becomes the sole machine format. ```text # BEFORE — two inconsistent renderings of one command [h] $ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup] [h] [wt-trace] ts=11593 tid=8 seq=5 context=worktrunk.rev-parse-dedup cmd="git rev-parse --show-toplevel" dur_us=6101 ok=true # AFTER — start ($) and finish (✓) pair, one `cmd [ctx]` rendering, no clutter [h] $ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup] [h] ✓ git rev-parse --show-toplevel [worktrunk.rev-parse-dedup] 6.1ms ``` Commands render `$ …` (start) → `✓`/`✗ … dur` (finish), in-process spans `◷ name dur`, milestones `· event` — the leading glyph names the line type at a glance. ## What a reviewer needs - **`src/trace/parse.rs`** — rewritten to parse `trace.jsonl` (one JSON object per line) via a `kind`-dispatch (`cmd_completed`/`cmd_errored`/`instant`/`span`), skipping non-records (`{"message":…}` logs, `$ cmd` echoes, non-JSON). Cleaner than inferring kind from which fields are present. - **`src/logging.rs`** — `format_wt_trace` now emits the human line; `style_stderr_line` bolds the command for `$`/`✓`/`✗` lines so a start and its finish read as a pair. The machine fields (`ts`/`tid`/`seq`) live only in `trace.jsonl` (via the separate JSON visitor). - **Consumers repointed at `trace.jsonl`**: `wt config state logs profile`, the `wt-perf` helper (`timeline` now runs `wt -vv` and reads the file, located via `git rev-parse --git-common-dir`), and the `diagnostic.md` profile section (while still inlining the human `trace.log`). - **Docs** — help text, the FAQ file inventory (added `trace.jsonl`), and the `[wt-trace]`-grammar descriptions across `emit.rs`/`log_files.rs`/`benches/CLAUDE.md` updated. One behavior note: `wt-perf timeline` now writes trace files to the repo's `.git/wt/logs/` (a side effect of `-vv`) where the old `RUST_LOG=debug` path didn't — expected for a `-vv`-based perf helper. ## Testing Well-covered: parser (14 unit tests), renderer + stderr styling (8), `logs profile`/`diagnostic`/`switch`/`completion` integration tests migrated to JSON fixtures, plus a `wt_target_dir` unit test for the wt-perf `-C` resolution. Verified `logs profile`, `wt-perf timeline` (text + `--chrome`), and `cache-check` end-to-end on a real `-vv` capture. An independent review pass found no correctness issues; its findings (a missed doc, two wt-perf robustness fixes) are folded in. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d5be156092 |
perf(list): plan background tasks from the columns being rendered (#3274)
## Problem `[list] columns` filtered purely at the layout layer. A narrowed selection like `columns = ["branch", "path"]` hid the unselected columns but still ran every per-worktree git task — `git status`, working/branch diffs, ahead/behind walks, merge-conflict probes — then threw the results away. On the kind of repo that motivated #3133 (27 dirty worktrees) that discarded work is the bulk of the wall-clock cost, so a "just branch and path" view was no faster than the full table. This was flagged in the [trace-based diagnosis on the issue](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4816169750): `columns = ["branch","path"]` and the default set produced an **identical** command list. @max-sixty [confirmed](https://github.com/max-sixty/worktrunk/issues/3133#issuecomment-4819594461) it's a bug and asked for the fix. ## Solution `wt list` decides which background tasks to run in **one canonical stage**, driven by the columns it will render. The plan flows through the whole pipeline as a **positive set of tasks to run** — no skip-list, no inversion, no blanket default. `collect` computes `tasks` = the union of each rendered column's `required_tasks()`, gated by the conditions that turn a column off (`--full`, `[list] summary` + `[commit.generation]`, a url template). The spawn loop fires exactly that set; the layout renders exactly the columns it feeds. The rendered set is the `[list] columns` selection for the table; the picker and `--format json` plan from every column, because their consumers — the picker's preview tabs, JSON's every-field contract — need the full data set, not just what renders. This started as additive pruning layered on the old `skip_tasks` denylist; review (thanks @max-sixty) pushed it to the canonical, positive form: - **One column→task map.** `ColumnSpec::requires_task` is deleted; `ColumnKind::required_tasks()` is the single source, driving both the spawn plan and the layout visibility filter (`renders_given_run` — a column renders iff one of its tasks is in the plan). The two maps can no longer drift, so the reconciliation test is gone; the `cover_every_task` drift guard stays and gains teeth (an unconsumed task would never run, not merely be computed and discarded). - **A positive run set, end to end.** `CollectOptions` carries `tasks` (the run set), not a skip set — `collect` threads the plan straight into the spawn loops, the layout, and `max_pr_number` with no complement step. - **No blanket default.** `CollectOptions::for_columns(columns, gates)` derives the plan; nothing hand-writes a task set. The statusline declares what it renders (the full column set under full gates, no LLM summary) instead of leaning on "default everything". The picker rides `show_full` on `ShowConfig::Resolved`. - **One mechanism for the summary.** The per-item `SummaryGenerate && llm.is_none()` spawn guard is dropped: the column plan is the single authority on whether the summary runs, and `SummaryGenerateTask` already returns a clean error on a missing command. A branch/path `ls` alias over many dirty worktrees now runs no `git status`, diffs, or ahead/behind walks (#3133), while a column gated off elsewhere stays off. Behaviour is otherwise unchanged across default/selection × full/non-full × table/JSON/picker/statusline — no rendered-output snapshots move (the `help_config_*` snapshots move only from the columns-doc rewrite). ## Testing - Planner + filter units: `test_required_tasks_for_render` (the default set needs every task; a branch/path or custom-column view needs none; `Status` pulls in every status-feeding task; the gates drop `ci`/`url`/`summary` even when those columns are explicitly selected) and `test_renders_given_run` (the "render iff a task is planned" filter, including `Status` surviving while any signal runs). - `test_required_tasks_cover_every_task` drift guard retained: the union of `required_tasks()` across all built-ins equals the full `TaskKind` set, so no task can fall out of the now-load-bearing map. - End-to-end: `test_list_config_columns_prune_unused_tasks` (default set runs `git status --porcelain`; `columns = ["branch", "age"]` runs none) and `test_list_json_ignores_columns_selection` (`--format json` emits every field regardless of selection). - Reviewed by independent finder passes (line-by-line + removed-behavior, cross-file + picker/JSON equivalence, altitude + conventions) — no findings; each confirmed the task set is preserved bit-for-bit. Full `pre-merge` gate (all suites, fmt, clippy, docs-sync, PTY picker snapshots) green after merging `main`. Closes #3133 > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Maximilian Roos <m@maxroos.com> |
||
|
|
a0f07ac6d7 |
fix(list): diff branches against the default branch's upstream tip (#3280)
## Problem
`wt list`'s `main↕` (ahead/behind) and `main…±` (branch diff) columns
measured every branch against the **local** default-branch tip. In a
fork whose local `main`/`master` lags its upstream, those columns
reported garbage. Local default branches go stale routinely: you fetch
far more often than you fast-forward local `main`, while the
remote-tracking ref refreshes on every fetch.
Concretely, in a fork of `skim-rs/skim` whose local `master` sat 42
commits behind `origin/master`, every feature branch displayed as `↑44`
ahead and `+∞ / -5K`, when each was only ~2 commits past the real
upstream tip. The workaround was a manual `git merge --ff-only
upstream/master` to un-stale the local base before `wt list` made sense
again.
## Root cause
`wt list` already carried two notions of "the mainline ref":
- **Integration status** (`⊂` / `↑` / `↓` …) resolves the default
branch's upstream and compares against the *superset* of `{local
default, its upstream}` via `Repository::integration_targets()`.
- **The two stat columns** ignored that and diffed against the raw local
`default_branch`.
That inconsistency is the bug. A stale local default inflated the counts
by every commit it was missing, and the integration symbol and the
numbers next to it could disagree.
## Fix
Route the two informational stat tasks through the same upstream-aware
superset ref the integration column already uses. A new
`TaskContext::comparison_base()` returns `integration_targets.primary`
(the superset side), falling back to the raw `default_branch` only when
integration targets could not be resolved (snapshot capture failed).
`AheadBehindTask` and `BranchDiffTask` now consume it instead of
`default_branch()`.
This reuses one mechanism rather than adding a second upstream resolver,
and is correct across all four local-vs-upstream relationships:
| Relationship | base | vs. before |
|---|---|---|
| no upstream / local == upstream | local | unchanged |
| local behind upstream (stale fork) | **upstream** | **fixed** |
| upstream behind local (unpushed local merge) | local | unchanged |
| diverged | local | unchanged |
The "upstream behind local" row is why naively preferring the upstream
ref would be wrong. After a local `wt merge` that has not been pushed,
the default branch leads its upstream, and sibling branches must still
measure against the local tip so the unpushed merge commits do not leak
into their counts. Reusing `integration_targets.primary` gets this case
right for free, because its superset selection already keeps the local
ref there.
### Deliberately unchanged
- **Conflict columns** (`✗ WouldConflict`, via `MergeTreeConflictsTask`
/ `WorkingTreeConflictsTask`) keep using the local default branch. They
predict the local `wt merge`, which targets the local ref.
- **`wt merge` target** and **`wt switch --base`** (writes) keep using
the local branch. You merge into and update a local ref, and a new
branch defaults onto a writable local branch; a remote-tracking ref is
neither.
### Side effect
The `↑`/`↓`/`↕` gutter symbols derive from these counts
(`is_same_commit` in `model/item.rs`), so they now also track the
upstream tip in a lagging fork. A branch sitting at the stale local-main
tip renders `⊂` (its content is already in the real mainline) rather
than `_`. Removal safety is unchanged: the safe-to-remove signals
(`is_ancestor`, `trees_match`) were already upstream-aware.
## Performance
The snapshot's batched `%(ahead-behind)` walk is keyed on the local
default-branch name, so the common case (superset == local) keeps the
batch. Only the lagging-fork case falls back to a per-pair
`ahead_behind_by_sha`, which is SHA-cache-backed, and that case had
wrong numbers before. The diverged case stays local, so it keeps the
batch too.
## Tests
Two regression tests in `tests/integration_tests/list.rs`:
- `test_list_branch_stats_use_upstream_when_local_default_lags` covers
the fix. It fails on `main` with `ahead 5` against the expected `2`.
- `test_list_branch_stats_stay_local_when_default_ahead_of_upstream`
pins that the base stays local when the default leads its upstream.
The full `wt hook pre-merge` gate is green (4262 tests, clippy,
docs-sync, rustdoc).
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
02c0621b3d |
fix(list): rank Integrated (⊂) above WouldConflict (✗) in main-state (#3278)
A squash-merged branch whose default branch later re-edited the same lines showed `✗` (WouldConflict) in `wt list`, while `wt step prune` correctly classified it as `⊂` (all changes in main) and removed it. The list and the prune verdict disagreed on the same branch, so `✗` read as "unmerged work that conflicts" when the branch was in fact fully integrated and safe to delete. The conflict is real but vacuous: a 3-way re-merge collides on the lines the default branch re-touched, yet the branch's whole diff already matches a commit on the default branch (patch-id), so resolving the merge just reproduces the default branch's tree — nothing added. That patch-id check is exactly how `prune` reaches `⊂`; the list simply ranked the downstream conflict above the integration verdict. This reorders gate 3 of the main-state column so the integration family (`⊂`/`_`) outranks `WouldConflict`, mirroring the existing rule that `Orphan` outranks it — show the root-cause state, not the downstream conflict. Integration stays fire-or-continue so the common `↑`/`↓`/`⊂` cases still render promptly; only `✗` is held until the integration verdict is final, so an integrated-but-stale branch never flashes `✗` before settling to `⊂`. A genuinely un-integrated conflict still shows `✗`. Well covered by unit tests on the priority logic and three new gate tests pinning the integrated-outranks-conflict, no-flash-while-loading, and genuine-conflict cases. The list's rendered table output is unchanged (the integrated-and-conflicting combination doesn't occur in existing fixtures); a follow-up commit reorders the `wt list` default-branch symbol docs and regenerates the `--help` snapshots to match the new priority. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8e2ab04c7d |
feat(switch): rework the alt-x picker removal — morph in place, decline current worktree, identity cursor (#3262)
Overhauls the `wt switch` picker's `alt-x` (remove) so a removal updates the selected row in place instead of re-collecting the whole list, declines removals that can't safely happen with an explanation, and lands the cursor on the right row afterward — including under an active filter. ## What changes for the user `alt-x` on a row now behaves by what the removal does: - **Unmerged worktree** → the row morphs in place to a `/ branch` row (worktree gone, branch kept); the worktree removal runs in the background. No flicker, cursor stays put. - **Merged worktree** → the row drops; the cursor lands on the row that slid up into its slot. - **Current worktree (`@`), main, dirty, locked** → kept, with the same diagnostic `wt remove` prints (drained to stderr on picker exit) instead of a silent dead keypress or a disruptive cd-home. Before, `alt-x` re-collected the entire list (a visible flicker, cursor reset to top), and removing the current worktree forced a `cd` elsewhere mid-render. ## The cursor fix (last commit) The post-removal reposition scrolled to the removed row's index in the full `shared_items` list. Under a fuzzy query, skim's `item_list` is filtered and reordered, so that index pointed at the wrong row — the cursor jumped +N rows down (N = filtered-out rows above it), compounding on a second removal. Reposition is now by **row identity**: it walks `item_list` to the target row's `output()` token. The drop's landing row (the display-neighbor) is captured before the reload via a native `alt-x` binding (`[capture, reload]`); keep/morph/restore land on their own row's token. ## Reviewing The bulk is `src/commands/picker/mod.rs`. Key pieces: - `invoke` dispatch (`removal_targets_current_worktree` → `worktree_removal_keeps_branch` → `removal_will_remove_target`) decides keep / morph / drop. - `morph_and_remove_in_background` does the in-place row morph (optimistic, reverted if the worktree unexpectedly survives). - `reposition_cursor_action` / `install_remove_keybinding` are the identity-based cursor landing. ## Testing Picker unit tests plus PTY integration tests through real skim, including the new `test_switch_picker_alt_x_lands_on_neighbor_under_filter` (removes a row with filtered-out decoys above it — confirmed to fail on the old index-based reposition and pass on identity). Multi-row cursor tests cover both drop and keep paths. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6fc6b37b6b |
feat(step): add require-include option + CLI flags to copy-ignored (#3196)
`wt step copy-ignored` copies all gitignored files by default. Add a `require-include` setting that gates the copy on a `.worktreeinclude` file existing in the source worktree — matching Claude Code desktop, where the file is required. Without `.worktreeinclude`, the command is a no-op that reports why (text hint + JSON `reason`); with it, only matching files copy. Config (additive, defaults off; `Option<bool>` merged via `.or()` so an explicit value overrides lower-priority config in either direction): ```toml [step.copy-ignored] require-include = true ``` CLI flags override config per run (precedence `CLI > --config-set > project > user > default`, mirroring `wt merge` / `wt remove`: ``` --require-include copy nothing unless .worktreeinclude exists --no-require-include copy all gitignored files this run ``` The gate short-circuits before `git ls-files` discovery. The recovery hint names `--no-require-include` (correct whether the flag or config triggered it, since the flag overrides both). `wt step promote` is unaffected — it exchanges all gitignored artifacts by design. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a725a2d786 |
Unify wt switch and --prs: differ only by the extra PR rows (#3252)
Full filter parity (branch, path, PR number/title/author) across worktree and --prs rows via a shared token builder; --prs dedups already-shown branches; worktree match text folds in PR data live as the forge fetch lands. Author plumbed through both forges. |
||
|
|
830cc850dd |
feat(list): show the main…± column by default; --full gates only off-machine columns (#3236)
Move the `main…±` branch-diff column (line diffs since the merge-base) into the default `wt list` view — it's pure local git backed by a persistent content-addressed cache, so the original blocking-walk concern no longer applies. `--full` now gates only the two off-machine columns: CI status (network) and LLM branch summaries. The interactive picker (`wt switch`) follows suit and is effectively `wt list --full`; on narrow terminals with the preview shown, CI clips past the split and alt-p reveals it. Also adds a `.typos.toml` ignore rule for truncated word fragments glued to the … ellipsis, so the narrower Message column's truncated quickstart embed doesn't get spell-"corrected" by pre-commit.ci. |
||
|
|
de97744718 |
feat(switch): picker shortcuts — copy, open, refresh, remap remove to alt-x (#3233)
Adds four keyboard shortcuts to the interactive `wt switch` picker: `alt-y` (copy the selected branch), `alt-o` (open the row's PR/MR), `alt-r` (refresh the list), and `alt-x` (remove, remapped from `alt-r`). `alt-y`/`alt-o` are native skim `Action::Custom` callbacks reading the selected row off `App.item_list` (no reload, so the cursor stays put and `--prs` rows aren't dropped); the row → branch/URL lookup is extracted into `resolve_shortcut_branch`/`resolve_shortcut_url` and unit-tested. `alt-r` refresh re-enters the collect pipeline via a new `PipelineFactory`. `alt-y` no-ops on a detached worktree (no branch). New cross-platform deps: `arboard` (clipboard) and `open` (browser), gated behind the `cli` feature. Merged over `codecov/patch` (89.76% vs 97.61% target) with explicit approval: the uncovered residual is the picker's clipboard/browser/thread-spawn and skim-`App`-bound closures, which a headless CI runner can't exercise. All required jobs green; reviewed and approved by worktrunk-bot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f0ee196e6 |
statusline: color the rate-limit pace notice by expected-lockout severity (#3229)
The statusline pace segment (the rate-limit warning, e.g. `1.4×(10am–3pm)`) was a flat yellow whenever it showed. This grades its color by how bad the projected hit actually is, deepening dim → dim-yellow → yellow. ## Two axes, separated The segment already gated its appearance on **confidence** — `P(over)`, the Bayesian probability of crossing the cap before the window resets. But `P(over)` saturates near 1, so it can't also grade *severity*: a near-certain cross in the last hour and one halfway through the week both read ≈1. Color is now driven by a second metric, **`expected_lockout`**: the expected fraction of the window that will be spent throttled, integrated over the posterior on the consumption rate (7-point Gauss–Hermite). It keeps discriminating after `P(over)` has flat-lined, and degrades to ~0 on thin early-window bursts — so a 5× burst on Monday doesn't light up the segment. The displayed number stays the raw `u/t` pace; only the color carries the Bayesian severity. ## Per-window thresholds from duration, not magic numbers The same lockout *fraction* costs very different real time across windows: 0.30 of the 5-hour window is 90 min, 0.30 of the weekly window is ~2 days. So `colorize_pace` compares a **duration-weighted cost** `= lockout · √(window / 5h)` against one shared cutoff pair, rather than two hand-tuned per-window pairs. The 5-hour window keeps its existing calibration (weight 1); the weekly window is sensitized ~5.8×, escalating on real time forfeited. Duration drives this (known exactly, 33.6× apart) — not σ, which is only 1.17× apart and already folded into the posterior integral. ## Reviewing Start at `src/commands/statusline.rs`: `expected_lockout` (the severity metric + its module-header rationale), `lockout_cost` (the √-duration weighting), and `colorize_pace`. The `-vv` trace now logs `lockout` and the weighted `cost` for future threshold tuning. Well-covered: the metric (bounds/monotonicity), the color tiers, the cross-window weighting, and the refactor's behavioral equivalence of `p_over` are all unit-tested; the 5-hour window is unchanged (snapshots confirm). The threshold anchors (dim-yellow 0.10, yellow 0.30, exponent 0.5) are feel-calibrated and meant to be tuned from real traces. ## Note: included an unrelated CI fix Merging main surfaced a pre-existing failure in `test (linux)`: `switch_picker_alt_l_ignored_list.snap` was left stale by the #3226 merge race (it still rendered the old `Age` column while every sibling picker snapshot already shows `Remote⇅`). Main pushes don't run the full suite, so it slipped in and blocks any PR that merges with main. This PR regenerates that one snapshot — it's unrelated to the statusline change but required to get CI green. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8f17cb4a91 |
test(switch): run the interactive picker tests on Windows (#3221)
PR #3217 made `wt switch`'s interactive picker run on Windows, but its tests stayed `#[cfg(unix)]` — so the Windows CI leg proved the picker *compiled* without ever exercising its runtime. This ungates them so Windows CI drives the real thing. **What's ungated** (the test infra — `mock_commands`, the ConPTY handling in `tests/common/pty.rs` — was already cross-platform, so this is largely a gate flip): - `switch_picker_dry_run.rs` — the headless dry-run pipeline (collect → render → preview cache → summary spawn → warning drain). - The `--prs` dry-run tests in `switch.rs` — forge fetch + row render via the cross-platform mock `gh`/`glab`. - The two requires-tty snapshots — the no-TTY bail is uniform across platforms. - `switch_picker.rs` — the PTY/vt100 TUI snapshot tests. **Test-quality fix folded in:** the four `--prs` dry-run tests asserted only `output.status.success()`, but the fetch path is fail-soft (any error → stashed warning → exit 0). On a platform where the mock didn't resolve they would pass vacuously. They now assert the fetched row actually rendered (a `pr:42`/`mr:7` preview-cache entry, or the empty-list warning). **Also:** `/bin/cat` → `cat` in the summary test (resolves via the platform shell on Unix and Git Bash alike); removed the stale "picker is Unix-only" line that #3217 left in the `wt switch` docs (and regenerated the help snapshot). **Windows results (first full run):** of the ~50 ungated tests, 46 passed on Windows on the first run — including every PTY/vt100 snapshot test (skim renders byte-identically under ConPTY), the accept-path drain tests, and the `cat`-via-Git-Bash summary test. Two issues surfaced and are handled here: - The three interactive `--prs` picker tests failed because the test helper `mock_forge_env` joined mock-bin onto `PATH` with a hardcoded `:` separator — malformed on Windows (`;`), so the mock `gh.exe`/`glab.exe` was never found. Fixed to use `std::env::join_paths` (OS-aware), matching the dry-run path's helper. - `test_switch_picker_pre_switch_hook_requires_approval` exits 1 instead of 0 on Windows: declining the hook returns `Ok` on both platforms, so the failure is in the interactive approval prompt's stdin handoff after skim releases the ConPTY. Not reproducible locally — `#[cfg_attr(windows, ignore)]` with a TODO until a Windows box can debug it (still compiles on Windows, runs on Unix). This is a genuine picker-on-Windows gap surfaced by this work, tracked for follow-up. > _This was written by Claude Code on behalf of max_ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c3abee15ef |
docs(list): note --full requirement on summary and main.diff JSON fields (#3224)
Follow-up to #3220. That PR added the `--full` qualifier to the JSON `ci` field row; the `summary` field and the `main` object's `diff` sub-field have the identical omission. Both are gated by `--full` in the same `skip_tasks` block (`SummaryGenerate` and `BranchDiff` are dropped when not `--full`), and both Columns-table rows already say "`--full` only" while their JSON field rows didn't — so a consumer scripting plain `wt list --json` would see `summary`/`main.diff` always absent and misread the cause. This states the requirement on both rows, so all three `--full`-gated JSON surfaces (`ci`, `summary`, `main.diff`) read consistently. Edited in `src/cli/mod.rs` (the `after_long_help` primary source); the `docs/content/list.md` and skill-reference mirrors plus the two `--help` snapshots are regenerated. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f47f29a6f |
docs(list): note --full requirement on the JSON ci object (#3220)
The `wt list --json` Fields table described the `ci` object as "absent when no CI", which omits the actual gate: `ci` is only populated under `--full` (or `[list] full`, or the statusline JSON path) — the same `--full` requirement the Columns table and the `[list] columns` note already document. A consumer scripting plain `wt list --json` sees `ci` always absent and reasonably misreads it as "no CI configured" rather than "needs `--full`". This states the requirement on the field row: `--full` only, then absent when no PR/MR or branch workflow. Source edit is in `src/cli/mod.rs` (the `after_long_help` primary source); the `docs/content/list.md` and skill-reference mirrors plus the two `--help` snapshots are regenerated. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5ce076c22c |
fix(step): apply -C to the tethered command; tether the docs server (#3207)
Make `wt step tether` honor the global `-C` flag as the spawned command's working directory (consistent with `run_custom`), and use it to tether this repo's `zola serve` docs preview so it is reaped whenever the worktree is removed — replacing the fragile [post-remove] port-kill hook that leaked ~83 zola processes. |
||
|
|
c70eedf152 |
feat(switch): run the interactive picker on Windows (#3217)
## Run the `wt switch` interactive picker on Windows
The picker was gated `#[cfg(unix)]` because its preview-tab switching
(alt-1…7 jump to a tab; tab/shift-tab cycle) was implemented as skim
`execute-silent` keybindings that shelled out to `echo`/`tr`/`mv`
through a per-process state file. skim runs keybind commands through the
platform shell — `cmd.exe` on Windows, which has neither `tr` nor `mv` —
so that was the hard blocker. skim 4.x (the ratatui/crossterm rewrite
worktrunk already depends on) supports Windows.
This replaces the shell keybindings with native handling: the active tab
is now a process-wide in-memory `AtomicU8` (`PreviewStateData`), and the
keys are bound to `Action::Custom` callbacks inserted directly into
skim's `options.keymap` (resolved with skim's own `parse_key`, so they
match its event-loop lookup exactly). Each callback sets the mode and
returns `Event::RunPreview`. This drops the state file, the
`ModeWatcher` background poller, and `shell_escape::unix` — a net
simplification on every platform, not just a Windows shim.
With the shell dependency gone, the `#[cfg(unix)]` gate comes off the
whole picker, along with the now-stale gates on its dependencies — both
in source (`GitHubPrInfo`, `open_pr_status`, `SwitchPipeline`, the
column-grid types, `ShowConfig`, `PickerProgressHandler`,
`format_aligned`, `generate_summary`) and in `Cargo.toml`, where the
picker's TUI stack (`skim`/`ratatui`/`ansi-to-tui`/`tokio`) moved out of
`[target.'cfg(unix)'.dependencies]` into the main table so it's present
in the Windows dependency graph. The FAQ is updated accordingly.
### Where to look
- `src/commands/picker/preview.rs` — `PreviewStateData` is now
in-memory; `PreviewMode::next`/`prev` rotation.
- `src/commands/picker/mod.rs` — `install_preview_tab_keybindings` (the
native bindings) and a `ModeWatcher`-free `run_skim`.
- `Cargo.toml` — TUI deps relocated out of the unix-only target table.
- `src/commands/{mod,worktree/mod,worktree/switch}.rs`, `src/main.rs` —
picker / `SwitchPipeline` gate removal.
- `src/commands/list/{ci_status,layout,collect,render}.rs`,
`src/summary.rs` — transitive gate / dead-code-suppression removal.
### Testing
Unit tests cover the rotation logic (`PreviewMode::next`/`prev`) and the
keymap wiring; the existing PTY integration tests in
`tests/integration_tests/switch_picker.rs` drive the real picker and
assert tab switching end-to-end (alt-N jump, tab/shift-tab cycle +
wrap). CI is green on all three platforms — `test (windows)` confirms
skim 4.8 + frizbee and their transitive deps compile and the suite
passes on Windows MSVC, which is the question this PR set out to answer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> _This was written by Claude Code on behalf of max_
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|