mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
main
129 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cff085edf9 |
docs: fix misplaced copy buttons by dropping terminal frames (#4090)
On every shell code block the copy button sat 40px below the top of the
code: on the second line of multi-line blocks, and hanging below
one-line blocks such as `wt merge --no-ff` on /merge/. Shell blocks were
Expressive Code terminal frames, and the terminal plugin deleted their
title bar, but Expressive Code still offsets the copy button by that
bar's height.
## Changes
- `docs/astro.config.mjs` sets `defaultProps: { frame: 'code' }`, so no
block is a terminal frame and there is no title bar to remove. The
plugin's header-removal hook and its `props.frame = 'terminal'` go, and
so does the `wt-commands-only` class: mobile wrapping in `custom.css`
now keys on whether a block contains captured output
(`:has(.wt-output)`).
- Blocks with several commands had a whole-block copy button and
per-line buttons in the same corner, handed over on hover by opacity. A
hidden button still takes clicks, so clicking a line's button could copy
the whole block, and on touch screens one button covered another. Most
of these blocks are lists of alternatives, so they now get only per-line
buttons, and the hover rule is deleted.
- Those blocks wrap (Expressive Code's `wrap` prop) unless they carry
captured output, since a per-line button sits at the end of its line and
scrolled out of view on the long `jq` examples on /list/. A per-line
button is no taller than its line, so buttons on adjacent lines no
longer overlap.
- The two multi-command blocks that only work run in order, the FAQ's
stash recipe and the `Equivalent to:` block under `wt step diff`'s **How
it works** (in `src/cli/step.rs`), are now `bash` fences, the form
`docs/CLAUDE.md` prescribes for a copyable recipe, so they keep one copy
button for the whole recipe. Terminal `--help` renders `console` and
`bash` fences alike (it strips `$ `), and the help snapshots are
unchanged.
## Side effect
Expressive Code strips comment lines only from what terminal frames
copy. The eight `bash` and `powershell` blocks with comments on
/shell-integration/ now copy them, as console blocks already did. Pasted
into zsh without `interactivecomments`, each comment line prints
`command not found: #`; the commands still run.
## Tests
A browser test checks every copy button on every page, at 393px with
touch and at 1376px, for three things: it sits on the line it copies, it
is inside the visible code, and it doesn't overlap another button. These
checks fail against worktrunk.dev and against a build without the wrap
and the height cap. The built-site and plugin tests now expect per-line
payloads and no block payload on multi-command blocks, and a plugin test
pins that blocks carrying output never wrap.
> _This was written by Claude Code on behalf of max-sixty_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_013U96NY8qKtZhavBSwnfCYq
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
48626f2df9 |
fix(merge): honor git worktree lock when cleaning up (#4073)
## Summary - `wt merge` built a `RemovalPlan` by hand and skipped the lock check `wt remove` already had, so a successful merge could rename a `git worktree lock`'d feature worktree into trash and report success. - After merge, a locked worktree is now kept (`Worktree preserved (locked)`) the same way a primary worktree is kept. The shared staging path also refuses a lock, including under `--force`, so every removal caller is covered. ## Test plan - [x] `cargo test --lib -- git::remove::tests` (includes `stage_refuses_locked_worktree` and `stage_refuses_locked_worktree_even_with_force`) - [x] `cargo test --test integration test_merge_preserves_locked_worktree` - [x] `cargo test --test integration test_merge_fast_forward` - [x] `cargo test --test integration test_remove_locked` (existing lock tests still pass) - [ ] `git worktree lock .` in a feature worktree, then `wt merge` — merge succeeds, worktree stays, message names the lock reason Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
10315349f4 |
Scope docs heading anchors under each subcommand section (#4079)
Command pages on the site append every subcommand's help, so headings such as "Examples", "Options", and "Command reference" repeat down `/step/` and `/config/`. The heading-id plugin numbered the repeats by position (`/step/#examples-3`, `/step/#hooks-1`, `/step/#command-reference-7`), so an anchor pointed somewhere else once a same-named heading was added above it. `docs/src/plugins/stable-heading-ids.mjs` now scopes ids below each subcommand's H2 (a heading starting `wt `) by that section's id: "Examples" under `wt step push` is `/step/#wt-step-push--examples`. Page-level headings and the subcommand headings keep their ids, so the table of contents and links like `/step/#wt-step-copy-ignored` are unchanged. A slug never contains `--`, so a scoped id can't equal another heading's slug — a "Cache" heading under `wt config state` stays distinct from the `wt config state cache` section. With the site handling it, the CLI help keeps unqualified headings. #4000 renamed six `wt config` subcommand headings to "Approval examples", "Alias examples", and so on to avoid the numbering; they're back to "Examples", in `--help` as well. Every anchor inside a subcommand section changes once: `/step/#min-age-guard` is now `/step/#wt-step-prune--min-age-guard`. The two internal links that pointed at such anchors — the FAQ's copy-on-write link and `wt config approvals`' "Reading approval state" — are updated, and `test:site` checks every internal fragment. External links to the old anchors land at the top of the page. The search-index plugin (`pagefind-command-references.mjs`) now imports the subcommand-heading check from the heading-id plugin rather than keeping its own copy. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HUmx2Jd5mTK5TznqGDmLGp Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f194d5a13c |
Age branches by reflog entry in wt step prune; fix four doc claims (#4077)
`wt step prune` could delete a branch created a minute earlier. Its
min-age guard ages a branch that has no worktree by its oldest reflog
entry — the help text and the function's docstring both say so — but the
code read `git reflog show --format=%ct`, which is the committer date of
the commit the entry points at. `git branch foo main` on a default
branch whose last commit is more than a day old therefore looked days
old, and the next `wt step prune` removed it.
From `test_prune_orphan_branch_min_age`, a branch made 30 minutes before
the run, pointing at a month-old commit, with the default
`--min-age=1d`:
```console
$ wt step prune --yes # before
✓ Removed branch orphan-integrated (same commit as main, _)
✓ Pruned 1 branch
$ wt step prune --yes # after
○ Skipped orphan-integrated (younger than 1d)
```
The age now comes from the entry's own timestamp: `git reflog show
--date=unix --format=%gd` renders each selector as `<name>@{<epoch>}`.
`test_prune_orphan_branch_min_age` couldn't tell the two apart, because
the test harness gives the commit and the branch creation the same date;
its commit is now a month older than the branch, and the old code fails
it.
A branch with no reflog at all still counts as old enough, as before.
That includes a branch created only from a bare repository's own
directory, since a bare repository defaults `core.logAllRefUpdates` to
false; the docstring now says so. A branch created from inside a linked
worktree has a reflog.
Found while reviewing #4000, along with four doc claims corrected in a
separate commit:
- The hook page said `{{ vars.thing | upper }}` previews as `{{
VARS.THING }}`; `wt hook show --expanded` prints `'{{ VARS.THING }}'`,
shell-quoted like any other value.
- The agent-integration table checked `/wt-switch-create` for Codex and
Gemini while its footnote said it does nothing there; the mark moves to
the row label.
- The skill's agent-handoff instruction, followed literally for
OpenCode, dropped `run`; it now points at the tips section's note on
where a subcommand goes.
- `take_global_options`' docstring counted 11 Global Options blocks on
`wt config`; there were 12.
Reviewable files: `src/commands/step/prune.rs`,
`tests/integration_tests/step_prune.rs`, `src/cli/mod.rs` (one
sentence), `docs/src/content/docs/claude-code.md`,
`skills/worktrunk/SKILL.md`, `src/help.rs`, `CHANGELOG.md`. The skill
and plugin mirrors and the agent-skills digest are regenerated.
> _This was written by Claude Code on behalf of max-sixty_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01HUmx2Jd5mTK5TznqGDmLGp
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
89326f14af |
Publish shell integration, add a footer, dedupe global options (#4000)
Guides and site presentation: one home per topic, a shell-integration
page, a footer, and three rendering fixes.
**Global options were emitted once per command reference**
clap repeats the same ~20-line `Global Options:` block in every
reference it renders, so a page assembled from subdocs stacked 11 copies
on `/config/` and 13 on `/step/`. That padded the pages and gave site
search that many near-identical hits — "squash" returned both
`#command-reference` and `#command-reference-2`. `take_global_options`
cuts each reference at the heading as it is built, keeping only the
first; one `kept` flag threads through the subdoc expansion and the page
streams out rather than accumulating. Terminal `--help` renders through
clap directly and is unchanged.
The config page also carried colliding anchors — two "Hooks" (`#hooks`,
`#hooks-1`), two "Aliases", and seven "Examples" (`#examples` …
`#examples-6`) — now qualified at their source in `src/cli/config.rs`:
User/Project hooks, User/Project aliases, and
Approval/Alias/State/Cache/Log/Variable examples.
`/step/` still has its own set (eight "Examples", two "Options", two
"Arguments", plus "Staging" and "Dry run" pairs). Qualifying those moves
existing `/step/#examples-N` anchors, so it wants a pass of its own with
the inbound links audited; the deduplication above already removes 13
Global Options blocks from that page.
**Shell integration has a page**
Shell-integration debugging was skill-only: five named warning messages,
a PowerShell checklist, and the wrapper mechanism, with no site page —
while the FAQ's answer to "`wt switch` didn't cd" was to install the
Claude Code plugin. It is now `/shell-integration/`, offered first, with
the plugin as the second route. The `llms.txt` listing serves every page
as `/<slug>.md` from a hand-created symlink, so a new page was a 404 the
listing still advertised; the symlink is added and the sync now fails
when a listed page has none.
**Presentation**
- A site footer carries the version (read from `Cargo.toml` at build
time), releases, changelog and license. No page named any of them, and
`/code-signing/` was reachable only from inside a collapsed block on the
homepage. Starlight's `Footer` is wrapped rather than replaced.
- `wt list --full` renders 1157px inside an 800px content column, so 40%
of it sat behind a horizontal scrollbar with the pane beside the column
empty. A terminal frame now takes the whole pane where there is slack,
measured with a query container rather than recomputed from Starlight's
layout formula.
- The `wt-command-reference` frames offered a copy button for 3,877
characters of generated help text; they now expose no copy control. A
console block listing several commands is as often a menu of
alternatives as a recipe, and nothing in the markup tells them apart, so
every command line in such a block carries its own copy control
alongside the block's.
- The four command demos and the two hand-written figures get captions;
the 2.33 MB homepage GIF below the fold loads lazily.
**Sidebar order is pinned**
`site-navigation.mjs` told readers a
`test_sidebar_matches_frontmatter_order` would fail when the authored
sidebar and the pages' `sidebar.order` disagreed. No such test existed,
and the disagreement it describes is exactly what the survey found:
`remove` listed before `merge`, Agent integration ahead of
lower-numbered pages. The test is written, so the sidebar and the
`llms.txt` ordering derived from the frontmatter can't drift apart
again.
<details>
<summary>Guide corrections</summary>
- Tips & patterns was 26 flat H2 recipes in no order, all 26 in the
sidebar. They group under five H2s — setup and layout, aliases and
hooks, per-worktree services, working with agents, status/commits/logs —
with each recipe demoted to H3. Anchors are level-independent, so
existing `/tips-patterns/#…` fragments still resolve.
- `-x 'opencode run'` has been broken since 0.75.0 made `-x` a literal
program: it is `-x opencode -- run '<task>'`.
- The branch-summary preview moved from tab 5 to 6 when the unified-diff
tab landed; the recipe names the `summary` tab instead of a number.
- The Caddy recipe claimed `feature-auth` hashes to port 16460 — that is
`fix-auth`'s port. It is 18283.
- `_` in `wt list` is same-commit *and clean*; the
same-commit-with-changes glyph is `–`, which is not safe to delete.
- `wt step prune` removes branches with no worktree too, and the min-age
guard ages a worktree by its creation time and a bare branch by its
oldest reflog entry.
- `wt step eval -v` prints fifteen variables; the example showed two
under a lead calling them "the available template variables".
- A filter applied to `{{ vars.<key> }}` acts on the placeholder the
preview substitutes, so `{{ vars.port | default('8080') }}` previews as
`{{ vars.port }}`, filter gone.
- The `.git/wt/cache/` table was missing `picker-preview`, and `wt
config state clear` prompts unless `--yes`.
- `skills/worktrunk/reference/README.md` was a symlink to the repo
README that `SKILL.md` never referenced, and the plugin mirror
dereferenced it into a 262-line copy carrying the star-history token,
share links, and a logo path resolving nowhere. Nothing generated it, so
deleting the symlink is the whole fix.
- One home per topic: agent handoffs stay in tips-patterns, activity
markers in `claude-code.md`, alias-template deferral in `extending.md`,
and the `codename` filter's two `worktree-path` recipes give way to the
config page that owns path templates. The FAQ's "Running tests" and "How
can I contribute?" duplicated the README's Contributing block down to
the share URLs.
- The FAQ linked `/worktrunk/#install`, the `noindex` compatibility
route; the plugin hook shim's Windows Terminal hint pointed there too.
Both use `/#install`, where the new sidebar Install entry goes.
- Example names settle on `myproject` / `feature-auth`; "sibling to main
repo" becomes "sibling to the main worktree", and `wt remove`'s "target
worktree" becomes "the worktree being removed" per the project's own
terminology rule.
</details>
UX survey items: `#36`, `#37`, `#38`, `#39`, `#45`, `#47`, `#48`, `#49`,
`#50`, `#51`, `#52`, `#61`, `#94`, `#95`, `#96`, `#97`, `#99`, `#100`.
Reviewable files: the hand-written pages under `docs/src/content/docs/`
(notably the new `shell-integration.md`, `tips-patterns.md`, `faq.md`),
`docs/src/components/Footer.astro`,
`docs/src/plugins/worktrunk-terminal.mjs`,
`docs/src/site-navigation.mjs`, `docs/tests/*.mjs`, `src/help.rs`,
`plugins/worktrunk/hooks/wt.sh`. Generated mirrors and snapshots are
regenerated.
> _This was written by Claude Code on behalf of max-sixty_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
f6b699a208 |
docs(hook): say post-merge runs in the destination worktree regardless of removal (#4071)
## Problem
`wt hook --help` lists the three cases where a hook's `cwd` differs from
`worktree_path`, and the `post-merge` entry qualifies itself with "with
removal":
> - `post-merge` with removal: the active worktree is gone, so the hook
runs in the target worktree
That condition does not exist. `post-merge` is anchored on the merge
destination unconditionally — `approve_merge_plan` adds it at
`destination_path` outside the `will_remove` branch, and
`finish_after_merge` registers it there whether or not the feature
worktree was removed. The stated reason is wrong too: the hook runs in
the destination because it is *about* the destination, not because the
source worktree happens to be gone.
The qualifier is also the only place on the page that says this. The
hook-type table ("Runs in the target branch worktree if it exists,
otherwise the primary worktree") and the merge-pipeline paragraph
("post-merge, post-switch and post-remove in the destination") both
state the unconditional behavior, so the page contradicts itself — and
the bullet is the version a reader lands on when they are specifically
asking which tree the hook sees.
The failure it invites is concrete: under `--no-remove` both worktrees
are on disk, and a hook written from this bullet — a deploy, a build, an
external freshness check — inspects the pre-merge feature worktree while
reporting on the integrated result. #4070 asks exactly this question.
## Solution
Replace the qualifier with the behavior the code implements, naming the
`--no-remove` case explicitly since that is the one the old wording got
wrong:
> - `post-merge`: the hook runs in the target branch's worktree (the
primary worktree if the target has none), including under `--no-remove`,
where the merged worktree `worktree_path` names is still on disk
Source is `after_long_help` in `src/cli/mod.rs`; the three generated
mirrors are regenerated by `test_docs_are_in_sync`. No `--help` snapshot
captures this text.
## Testing
`test_merge_post_merge_runs_in_destination_with_no_remove` merges a
feature worktree with `--no-remove` and a `post-merge` hook that writes
a marker relative to its cwd, then asserts the marker lands in the
destination worktree and *not* in the preserved feature worktree. It
passes against unchanged code — the behavior was already correct, and
the test pins it so the documented contract cannot drift back.
`integration_tests::merge::` (153 tests), `integration_tests::help::`
(59), `integration_tests::readme_sync::` (18), `cargo fmt --check`, and
`cargo clippy --all-targets` all pass locally.
Closes #4070 — automated triage
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
|
||
|
|
28ebf24505 |
Drop post-commit when the merge removed the worktree it runs in (#4049)
## Problem
`wt merge` prints `◎ Running post-commit: …` and then spawns that
pipeline into a path the removal has already emptied. `post-commit` is
the only hook in the merge's background batch anchored on the feature
worktree, and `HookAnnouncer` flushes after `finish_after_merge` has
removed that worktree.
`run_pipeline` calls `Repository::at` on what's left, and git discovery
walks up from it:
- **Worktree outside the repository** (the default `../{{ repo }}.{{
branch }}`): nothing above it is a repository, so the runner logs
`failed to open repository for pipeline` to
`.git/wt/logs/<branch>/<source>/post-commit/runner.log`, which nothing
reads back. The hook doesn't run.
- **Worktree nested inside the repository** (`{{ repo_path
}}/.worktrees/{{ branch | sanitize }}`, one of the config page's own
examples): discovery resolves to the **primary** worktree, so the hook's
commands — arbitrary project code from `.config/wt.toml` — run against a
checkout the user never chose.
[#4026](https://github.com/max-sixty/worktrunk/pull/4026) documented
both outcomes rather than fixing them.
## Solution
The removal is the only thing that knows the anchor is gone, so it says
so: `spawn_hooks_after_remove` calls
`HookAnnouncer::mark_worktree_removed(ctx.worktree_path)`, and the flush
drops any pending pipeline anchored on a marked path. The survivors get
the usual `Running …` line; each dropped pipeline gets its own warning.
```
▲ Skipped post-commit: mark (user) — worktree removed @ ~/code/myproject/.worktrees/feature
↳ To run commands in a worktree before it is removed, use pre-remove
◎ Running post-remove: cleanup (user); post-switch: notify (user); post-merge: sync (user) @ ~/code/myproject
```
Reading the fact from the removal rather than probing the filesystem is
what makes the skip unconditional. The two removal paths leave the
anchor in different states: the fast path renames the worktree into
`.git/wt/trash/` before the flush, but where that rename fails —
cross-filesystem, permissions, Windows file locks —
`BackgroundFallbackMode::Detached` spawns `git worktree remove` and the
anchor is still on disk, intact, when the flush runs. A "does this path
still hold git data" probe answers `true` there and spawns the hook into
a worktree being deleted underneath it. The mark doesn't depend on which
path ran.
Deciding at the flush keeps every case where the removal doesn't happen:
`--no-remove`, merging on the target branch, merging from the primary
worktree, and a removal blocked by a dirty worktree all leave the anchor
in place, and post-commit runs there as before — as it does on `wt step
commit` and `wt step squash`. Only `wt merge` can reach the drop: every
other background hook anchors on a worktree its command keeps
(`post-merge`, `post-switch` and `post-remove` all render against the
destination).
There is no earlier moment to spawn it. Between the commit and the
removal the worktree is rebased and runs `pre-merge`, so a background
pipeline started there would race both.
## Testing
`test_merge_post_commit_runs_only_when_its_worktree_survives` in
`tests/integration_tests/user_hooks.rs` is parameterized over removal.
The worktree is nested inside the repository so that a regression
*executes* rather than merely fails: the hook writes `git rev-parse
--show-toplevel` to a marker in the primary worktree.
- `removed` — asserts the marker never appears, and reports the resolved
toplevel if it does. Dropping the partition writes the primary
worktree's path there.
- `kept` (`--no-remove`) — the control: same hook, same marker path,
worktree survives, and the marker names the feature worktree.
The mark is unconditional in `spawn_hooks_after_remove`, which every
removal path reaches after its removal, so there is no
fast-path/fallback branch left for a test to distinguish — the first
revision of this PR put a `holds_git_data` probe there instead, and
`test (windows)` caught the fallback taking the other answer.
The two merge announce snapshots now show the `Skipped` lines and a
`Running` line without `post-commit`; both tests are renamed to the
three hook types they still combine. `cargo run -- hook pre-merge --yes`
passes.
<details>
<summary>Manual repro</summary>
A scratch repo whose `post-commit` hook is `git rev-parse
--show-toplevel > {{ repo_path }}/toplevel.txt`, a feature worktree with
one commit and one uncommitted file, then `wt merge main --yes`.
Without the drop, the nested layout runs the hook and the marker names
the **primary** worktree, while the hook was anchored on
`.worktrees/feat`:
```
/private/var/folders/.../tmp.6MtN6Ufppv/repo
```
With it:
| Layout | Flags | Result |
|--------|-------|--------|
| nested (`{{ repo_path }}/.worktrees/{{ branch \| sanitize }}`) |
default (squash) | `▲ Skipped post-commit`, no marker |
| nested | `--no-squash` | `▲ Skipped post-commit`, no marker |
| external (`../{{ repo }}.{{ branch }}`) | default (squash) | `▲
Skipped post-commit`, no marker |
| external | `--no-remove` | `◎ Running post-commit`, marker names the
feature worktree |
</details>
> _This was written by Claude Code on behalf of max-sixty_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01QG3SjDDtiVkvmk3eZDc6vu
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9f28b1267f |
Make wt config show a real diagnostic (#3999)
`wt config show` could identify broken configuration while still exiting 0, which made it unsuitable as a scripted health check. This makes its exit status reliable without truncating the human report or corrupting JSON output. ## What changed - `wt config show` renders every text section before returning non-zero for unreadable or invalid config sources, invalid list-column settings, or an invalid approvals file. Unknown and deprecated keys remain warnings and exit 0. - JSON output stays parseable on failure, preserves merged file layers when runtime overrides are invalid, and reports an unreadable or invalid source as `null`. - Project config loaded from the Git object store is reported as that source rather than as a missing file. Pending project commands appear in a compact `APPROVALS` section. - `wt config update --output` warns when it omits deprecated `approved-commands`, and refuses to overwrite the source only in that lossy case. The scope is intentionally limited: there is no derived `EFFECTIVE` section, no unconditional empty system-config section, and no broad rewrite of the configuration guide. The command help adds only the changed behavior and is synchronized to the generated docs and skill reference. Validation: the pre-merge hook and coverage suite passed all 4,826 tests, Clippy, formatting, snapshots, doctests, and documentation checks; the Astro production build passes. > _This was written by Codex on behalf of max-sixty_ --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
134f9eb471 |
Default wt list JSON output to schema 2 (#4038)
`wt list --format=json` and `wt list statusline --format=json` now emit the schema 2 envelope when `[list] json-schema` is unset. Explicit `json-schema = 1` keeps the legacy bare array, while invalid values warn and fall back to schema 2. This removes the completed pending-default migration and its `wt config update` prompt, and refreshes help, docs, config examples, and snapshots. Tests: - `cargo run -- hook pre-merge --yes` - `npm --prefix docs run build` > _This was written by Codex on behalf of max-sixty_ |
||
|
|
a0ad432a04 |
docs: fit terminal examples on desktop (#4039)
Widen the documentation content rail and defer the right-hand table of contents until the viewport can accommodate it, so terminal examples use the available desktop space. Capture every documentation-backed `wt list` example at the shared 98-column width. Full-mode examples now exercise Worktrunk's real column hiding and truncation instead of embedding 134-column output, and the activity example no longer inherits the test suite's 500-column default. Add a browser regression that crawls every public route at both sides of the responsive breakpoint and rejects horizontal overflow in output-bearing terminal blocks. > _This was written by Codex on behalf of max-sixty_ |
||
|
|
981a207e26 |
Fix wt list column sizing, alignment, and headers (#3998)
Seven fixes to `wt list` — three to how the table lays out, one to what
detached and prunable worktrees show, and three to the page that
documents it.
**The table**
A single 58-character branch name sized the Branch column for every row:
at 60 columns the table degenerated into a branch list with nine columns
hidden, and at 200 it still lost Message. Branch now sizes to
`min(longest, 32)` and elides with `…`, the way Message already does.
`--format=json` still carries the whole name.
`Remote⇅` in a repo with no remote held its blank seven columns open
while Message, Commit, Age and Path — each with something to say on
every row — were dropped for want of them. The empty-column penalty now
exceeds every base priority, and the allocation loop stops admitting
empty columns once a populated one has failed to fit. `Remote⇅` also
learns it is empty before any task reports: a repo with no remote has no
branch that can track one, read O(1) off the bulk config map.
Alignment now follows the value type consistently: text and reference
columns align left; the scalar `Age` column and its header align right;
split diff fields use two right-aligned halves with centered headers and
centered whole-field states such as loading or in-sync markers. Rows no
longer carry trailing padding, and the hidden-column footer wraps at the
terminal width. The progressive renderer documents and asserts the
corresponding terminal invariant: ordinary rows and the loading footer
occupy one physical row, while only the final summary may wrap.
The familiar `main↕` and `main…±` labels remain fixed across
repositories. This avoids adding default-branch-name plumbing or a `^`
fallback without changing the meaning of either column.
**Detached and prunable rows**
A detached worktree rendered as a bare hash under Branch, wearing `⚑` on
loan from `branch_worktree_mismatch` — which it was only flagged with
because a worktree with no branch has no branch-implied path to sit at.
It gets `⊘`, and stops claiming to be off-template, so `⚑` again means
only what it says. A prunable worktree showed four `·` loading glyphs
that never resolved: its directory is gone, so no task is ever spawned
for it and no cell is coming. Those cells render blank, leaving `⊟` in
Status as the row's whole story.
**The page**
<details>
<summary>Documentation corrections</summary>
- The summary footer counted hidden columns ("3 columns hidden") without
saying which, and the page never explained that the table drops columns
to fit. The footer now names them ("hidden: Path, Commit, Message"), and
a paragraph under `## Columns` covers the drop order, `[list] columns`,
and that `--format=json` shows everything.
- The JSON section documented deprecated schema 1 at 159 lines and nine
sub-tables while schema 2 — what a future release makes the default —
got 88 lines and one table. Schema 2 now carries the full reference
(envelope, item fields, a sub-table per object, the three value
vocabularies) and the worked `jq` recipes. Schema 1 keeps a paragraph,
the deprecation pointer, and a schema-1 → schema-2 mapping table
covering every field it documented.
- The CI-cache line pointed at `wt config state`, a group with no
default action; it names `wt config state cache`.
- `wt list statusline --format=json` was documented as "a one-entry
array in the `wt list --format=json` schema", which holds only under
schema 1. It emits the current schema — an array under 1, the envelope
under 2 — and the line now records why that surface stays silent about
an unset `[list] json-schema` while plain `wt list` nags: a prompt
consumer can't act on a warning drawn over its own line.
- The Status-symbols section documented every subcolumn except the
branch marker. Both JSON schemas gain an additive `marker` field, so the
value is readable without parsing it back out of `symbols`.
</details>
UX survey items: `#24`, `#34`, `#58`, `#89`, `#90`, `#92`, `#93`.
Reviewable files: `src/commands/list/**` (`layout.rs`, `columns.rs`,
`render.rs`, `progressive_table.rs`, `model/item.rs`, `mod.rs`,
`json_v2.rs`), the list section of `src/cli/mod.rs`,
`src/styling/line.rs`, and
`.claude/skills/writing-user-outputs/SKILL.md`. Generated mirrors and
snapshots are regenerated.
Original analysis:
https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb
> _This was written by Codex on behalf of max-sixty_
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
5a552b29e7 |
docs(faq): list the agent-integration files worktrunk writes outside its own config (#4036)
`CLAUDE.md` names the FAQ's [What files does Worktrunk create?](https://worktrunk.dev/faq/#what-files-does-worktrunk-create) and [What can Worktrunk delete?](https://worktrunk.dev/faq/#what-can-worktrunk-delete) as the full inventory of worktrunk's file surface, and tells reviewers to check new code against them. Neither section has ever covered `wt config plugins <agent> install`, so the inventory has been incomplete since the OpenCode plugin landed, and adding the Pi hook this week widened the gap. The sections now carry an "Agent integrations" entry listing the three files worktrunk writes into another tool's config tree, and the "does NOT create" bullet — which read "No files outside `.git/`, config directories, worktree directories, or the system temporary directory" — now says which agent paths are the exception and that they are only touched when you run the install command. `wt config plugins {opencode,pi} uninstall` is added to the deletion list. Nightly sweep finding; no linked issue. <details><summary>What each claim is grounded in</summary> Files worktrunk writes directly: - `<opencode config dir>/plugins/worktrunk.ts` — `opencode_plugins_dir` in [`src/commands/config/opencode.rs`](https://github.com/max-sixty/worktrunk/blob/74ad58ac6/src/commands/config/opencode.rs#L24-L44), precedence `$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > `~/.config/opencode`. - `<pi agent dir>/hooks/pre/worktrunk.ts` — `pi_agent_dir` / `plugin_path` in [`src/commands/config/pi.rs`](https://github.com/max-sixty/worktrunk/blob/74ad58ac6/src/commands/config/pi.rs#L20-L49), honoring `$PI_CONFIG_DIR`, `$OMP_PROFILE`/`$PI_PROFILE`, and `$PI_CODING_AGENT_DIR`. - `<claude config dir>/settings.json` — `handle_claude_install_statusline` in [`src/commands/config/plugins.rs`](https://github.com/max-sixty/worktrunk/blob/74ad58ac6/src/commands/config/plugins.rs#L91-L152), which merges the `statusLine` key (command `wt list statusline --format=claude-code`) into the existing object rather than replacing the file, and writes through `write_atomically` — the same `settings.json` that `CLAUDE.md`'s Data Safety section already names as a `write_atomically` target. Files worktrunk does not write itself: `wt config plugins claude install` and `codex install` shell out to `claude` / `codex`, which record the marketplace and plugin in their own config (`~/.claude/plugins/known_marketplaces.json` and `installed_plugins.json`, read back by `is_marketplace_configured` / `is_plugin_installed` in `show.rs`; `~/.codex/config.toml`'s `[marketplaces.*]` tables, read by `is_marketplace_configured` in `codex.rs`). Gemini is left out — worktrunk has no install path for it, only a `wt config show` status line pointing at `gemini extensions install`. The three `faq.md` copies stay in sync via `test_docs_are_in_sync`; the site page is primary and the two skill mirrors are generated. </details> No test: documentation only. `cargo test --test integration test_docs_are_in_sync` and `test_plugin_layout_is_consolidated` pass locally. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
fb6576946a |
feat(config): install the Codex plugin, not just its marketplace (#4019)
`wt config plugins codex install` registered the Worktrunk marketplace in Codex and then handed the rest back to the user (`↳ Next, run /plugins in Codex and install Worktrunk from the marketplace`). The Codex plugin CLI has had `codex plugin add PLUGIN@MARKETPLACE` since [`rust-v0.131.0`](https://github.com/openai/codex/releases/tag/rust-v0.131.0), so the install now runs it and converges on the Claude flow. Uninstall moves with it — it removes the plugin, then the marketplace — so the two commands are inverses again. Verified by the mock-driven `test_plugins_codex_*` tests; `cargo test --lib --bins`, `cargo test --test integration`, and `pre-commit run --all-files` pass locally. Per [#4017 (comment)](https://github.com/max-sixty/worktrunk/issues/4017#issuecomment-5561118514) there is no version floor and no fallback to the old hint: a failing `codex plugin add` or `codex plugin remove` surfaces as an error with codex's stderr in the gutter, the same way Claude's `plugin install` does. <details><summary>Settled point, and what wasn't verified</summary> **The uninstalls now agree.** Both harnesses remove the plugin and then its marketplace, so uninstall is the inverse of install for each. The Claude half landed in `f681fa1d1`; the policy it follows is settled in [a comment on this PR](https://github.com/max-sixty/worktrunk/pull/4019#issuecomment-5564279241), which also records what that costs someone who installed the plugin without `wt`. **Not verified from CI:** the sandbox has no `codex` binary, so the argv is pinned by the mock harness but the claim that `codex plugin add worktrunk@worktrunk` installs the plugin from our configured marketplace is read from upstream source, not executed. The non-remote branch of `run_plugin_add` at `rust-v0.153.0` is `find_marketplace_for_plugin` → `manager.install_plugin(...)`, which filters on marketplace name plus plugin name and reads no `PluginInstallPolicy` — so `"installation": "AVAILABLE"` in `.agents/plugins/marketplace.json` and the curated Git-source allowlist don't gate it. `RemovePluginArgs` takes the same `PLUGIN[@MARKETPLACE]` selector as `AddPluginArgs`. **Superseded during review:** an earlier revision made `codex plugin remove` best-effort, on the premise that it fails where no plugin is installed. It doesn't — the non-remote `run_plugin_remove` bottoms out in `PluginStore::uninstall`, which returns `Ok(())` for a path that isn't there — so that branch only ever swallowed genuine failures. It's gone; `d69dcf2` carries the removal. **Changed surface** - `src/commands/config/codex.rs` — the two handlers - `src/cli/config.rs` — `after_long_help` for both subcommands, plus their one-line `about`s (`Configure the Worktrunk marketplace in Codex` → `Install the Worktrunk plugin`) - `docs/src/content/docs/claude-code.md` and its generated mirrors - `src/testing/mod.rs` — `setup_mock_codex_with_plugins` gains `plugin add` / `plugin remove`; `setup_mock_codex_with_plugins_failing` fails all four; new `setup_mock_codex_with_plugin_ops_failing` isolates a plugin-op failure from the marketplace step </details> Closes #4017 --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
acc0fd06d8 |
Report whether copy-ignored reflinked or copied in full (#4025)
`wt step copy-ignored` told every user the same thing — `Copied 65,679 files · 29.5 GB` — whether the filesystem shared the source's blocks or wrote all 29.5 GB out. On ext4 and NTFS, which have no reflink, that is the difference between a free copy and a full one, and nothing in the output said which had happened. #4022 documented the caveat per filesystem; this reports it per machine. ``` ✓ Copied 4,812 files · 14.0 GB (reflinked, no extra disk) ✓ Copied 4,812 files · 14.0 GB (full copy) ✓ Copied 4,812 files · 14.0 GB (3,200 of 4,812 reflinked) ``` The signal was already in hand and thrown away. `reflink_or_copy` returns `Ok(None)` when the platform's clone syscall succeeded and `Ok(Some(bytes))` when it fell through to `fs::copy`, and `copy_leaf` matched `Ok(_)`. It now returns that alongside the byte count, `Progress::record` takes a `DataCopy` and counts each side, `Progress::copy_split` reads the pair back, and `--format=json` gains `reflinked` and `written` — with one rule and no exceptions: a payload reporting a **result** carries all four, zeroed where nothing was copied, and a **plan** (`dry_run: true`) carries none of them, since it says what would be copied rather than what was. That last half is a small behaviour change beyond the new keys — the `--require-include` and empty-entries returns fire before the dry-run branch, so under `--dry-run` they used to emit `files: 0, bytes: 0` while a plan with entries in it emitted neither. They now emit none, so `jq '.reflinked + .written'` no longer returns a number for one plan and null for another. `same_worktree` is unchanged: it reports a result rather than a plan and carries no `dry_run` key. Only the reflinked wording carries a gloss, since that is the term a reader won't know; the contrast then says what a full copy cost without repeating the byte count sitting two words to its left. <details> <summary>Three decisions worth a look</summary> **Symlinks record `DataCopy::Neither` rather than counting as written.** A symlink's content is a path, so it has no extents to share or to write. Counting it on either side would make a `node_modules/` full of bin shims report as a partial reflink failure on a machine where everything with data in it cloned fine. **`WORKTRUNK_TEST_REFLINK=1|0` pins the reported label**, while the copy still attempts a reflink either way. Whether a clone succeeds is a property of the filesystem under the test's temp directory, and CI spans APFS, ext4, and NTFS, so no one snapshot could hold on all three and the branch a given run took would be invisible. It is set per command in the copy-ignored tests rather than in `STATIC_TEST_ENV_VARS`, which reaches every child and would add an `env:` line to every snapshot in the suite. Eleven snapshots pin the reflinked branch and one new test pins `(full copy)`; the mixed case needs two filesystems under one tree, so unit tests cover its counting and its rendering. **`classify_copy` is a named function for the same reason.** The coverage job runs on `ubuntu-24.04`, so ext4 never reaches the reflinked arm and it would have posted as a patch miss. A unit test pins the mapping instead of whichever runner happens to execute it. </details> The signal is exact per file, but it says only that a clone did not happen, never why — an unsupported filesystem and a cross-device copy are indistinguishable here. That is enough for the claim the output makes (those bytes really were written) and not enough to assert "this filesystem has no reflink", which the wording avoids. `wt step promote` also calls `copy_leaf` and reports no split: its copy path only runs as the cross-device fallback when `rename` fails with EXDEV, where a reflink is impossible by definition. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NrPJmECkKALUaqxb9GKCC5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
38c8100527 |
Build the commit prompt from the worktree being committed (#3996)
`wt step commit --branch <b>` commits in another worktree, but built the LLM prompt from the invoking one. `build_commit_prompt` called `Repository::current()` and diffed from its discovery path, so with nothing staged in the cwd the generator got an empty diff, an empty diffstat, the invoking branch's name, and the invoking branch's recent commits — and produced a commit message describing none of the changes it was committing. `wt step relocate --commit`, which commits into each relocated worktree in turn, had the same bug for the same reason. `build_commit_prompt` and `generate_commit_message` now take the target `WorkingTree`, and every input — diff, diffstat, branch, repo root, recent commits — is read from it. `--show-prompt` and `--dry-run` resolve `--branch` the same way the real run does, through a shared `resolve_env`, so a preview describes the commit the same flags would make. `--dry-run`'s temp index is created in the target worktree too. Two accessors move from `Repository` to `WorkingTree`, since both answer per-worktree questions: - `recent_commit_subjects` walks back from HEAD, and HEAD is per-worktree. On the repository it answered for whichever worktree the repo was discovered from. - `diff_stats_summary` — every caller diffs the index or HEAD. Existing call sites that meant "the invoking worktree" say so explicitly (`repo.current_worktree().diff_stats_summary(…)`). New coverage: `--branch` against a worktree with staged changes in `tests/integration_tests/merge.rs`, and the relocate path in `tests/integration_tests/step_relocate.rs`. The `wt step commit` help said `--branch` "has no effect on `--dry-run`, which always previews the current worktree" — accurate before this change, and not after. It now says `--branch` selects the previewed worktree the same way it selects the committed one. UX survey items: `#103`. Reviewable files: `src/llm.rs`, `src/commands/step/commit.rs`, `src/git/repository/diff.rs`, `src/commands/commit.rs`, `src/cli/step.rs`, `tests/integration_tests/merge.rs`, `tests/integration_tests/step_relocate.rs`. Generated mirrors and snapshots are regenerated. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5327a068d5 |
docs(merge): record that post-commit can't run when the merge removes its worktree (#4026)
`wt merge` prints `◎ Running post-commit: …` and then spawns that
pipeline into a path the removal has already emptied, so a `post-commit`
hook can't be relied on to do what it says. Nothing reports this, and
two doc lines promised the behavior that doesn't happen.
`post-commit` is the only hook in the merge's background batch anchored
on the feature worktree — `post-merge`, `post-switch` and `post-remove`
all anchor on the destination. `HookAnnouncer` flushes every pending
pipeline once at the end of the command, which is after
`finish_after_merge` has removed that worktree.
What the runner then finds depends on where the worktree lived, because
the fast removal path renames it into `.git/wt/trash/` and leaves an
empty placeholder at the original path (`changed_directory: true`, so
the shell's `$PWD` stays valid), torn down a second later by the
detached `sleep 1 && rmdir`. `run_pipeline` calls `Repository::at` on
that placeholder, and git discovery walks up from it:
- **Worktree outside the repo** (the default `../{{ repo }}.{{ branch
}}`): no git ancestor, so the runner logs `failed to open repository for
pipeline` to `.git/wt/logs/<branch>/<source>/post-commit/runner.log`,
which nothing reads back. The hook doesn't run.
- **Worktree nested inside the repo** (`worktree-path = "{{ repo_path
}}/.worktrees/{{ branch | sanitize }}"`, one of the config page's own
examples): discovery resolves to the **primary** worktree,
`Repository::at` succeeds, and the steps do run — with their cwd set to
the placeholder `rmdir` unlinks a moment later, and any `git` inside
them resolving against the primary worktree.
It's a regression, not a hook that never worked: #1679 added
`post-commit` and it fired for five weeks, until #2457 collapsed the
merge's two or three `◎ Running …` lines into one. That was a cosmetic
change, and deferring the spawn to a single end-of-command flush moved
it past the removal.
Documented rather than fixed, deliberately. The commit `post-commit`
would fire on is squashed and rebased before the merge lands;
`pre-remove` already covers work that must finish in the feature
worktree, blocking removal until it does; and spawning at commit time
only narrows the window, since `git worktree remove` succeeds against a
live cwd. `post-commit` still runs properly wherever the worktree
survives — `--no-remove`, merging on the target branch, merging from the
primary worktree — and on `wt step commit` / `wt step squash`.
So this adds the explanation at the `HookAnnouncer` construction in
`handle_merge`, and corrects what the docs claimed:
- The merge pipeline's step 1 said "Post-commit hooks run in background"
flatly.
- The hook page said "During `wt merge`, hooks run in this order:
pre-commit → post-commit → pre-merge → pre-remove → post-remove +
post-merge" — wrong twice, since the `post-*` hooks don't run in
sequence either (#4020 corrected that one row above). It now gives the
blocking order, says the `post-*` hooks start together in the worktree
each is anchored on, and points at `pre-remove` or `--no-remove`.
- `test_merge_squash_combines_post_commit_…` said the merge "fires
post-commit"; it announces it. Both it and its auto-commit sibling now
say the announce line is all they pin.
No behavior change.
<details>
<summary>Reproduction</summary>
A scratch repo with a `post-commit` hook, a feature worktree with one
commit and one uncommitted file, then `wt merge main --yes`. Every case
prints `◎ Running post-commit: mark (user)`:
| Layout | Flags | Outcome |
|--------|-------|---------|
| external (`../{{ repo }}.{{ branch }}`) | default (squash) | hook
doesn't run |
| external | `--no-squash` | hook doesn't run |
| external | `--no-remove` | runs correctly |
| nested (`{{ repo_path }}/.worktrees/{{ branch }}`) | default (squash)
| runs in the doomed placeholder |
The external removing cases leave this in
`.git/wt/logs/feat/user/post-commit/runner.log`:
```
✗ failed to open repository for pipeline
fatal: not a git repository (or any of the parent directories): .git
```
The nested case leaves an empty `runner.log` and the hook's own output
shows where it landed — `pwd` is the removed worktree's path, while `git
rev-parse --show-toplevel` from inside it answers with the primary
worktree:
```
cwd=/…/repo/.worktrees/feat
toplevel=/private/…/repo
```
The commit itself is made in every case — `Changes to dirty.txt` reaches
`main`.
</details>
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
80e795b271 |
fix(plugin): run the Codex Windows hooks through Git Bash, not the WSL launcher (#4008)
## Problem Codex resolves a hook `command` through the platform shell — `/bin/sh -lc` on Unix, `cmd.exe /C` on Windows ([`default_shell_command`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/command_runner.rs)). All four Codex hooks in the plugin manifest lead with a bare `bash`, and under `cmd.exe` that resolves through the Windows PATH to `System32\bash.exe` — the WSL launcher, not Git Bash. In a sandboxed session the launcher refuses to start (`Access is denied. Error code: Bash/Service/CreateInstance/E_ACCESSDENIED`), so every prompt, permission request, turn end, and session end raises a `Hook failed` banner (#4007). ## Solution Git for Windows stays a requirement; only the bare *name* `bash` is the problem, since `cmd.exe` resolves it through PATH. Each hook now also carries Codex's per-handler `commandWindows`, which **replaces** `command` on Windows (`command_windows.unwrap_or(command)` in `codex-rs/hooks/src/engine/discovery.rs`). It calls a new `cmd.exe` shim, `plugins/worktrunk/hooks/wt.cmd`, that locates `bash.exe` by path the way [`find_git_bash`](https://github.com/max-sixty/worktrunk/blob/2405b8b434cfa9d7a604405d3ace966ada60ff8a/src/shell_exec.rs#L481-L518) does in `src/shell_exec.rs` — derived from `git.exe`'s install directory, then the system-wide and per-user install defaults — and then runs the same `hooks/wt.sh` that Claude, Gemini, and Unix Codex already go through. Worktrunk's own binary resolution stays in that one script instead of being spelled a second time in cmd. Two details the shim inherits from the Rust resolver: `Git\bin\bash.exe` before `Git\usr\bin\bash.exe`, because the former is the wrapper that sets up the MSYS environment for a caller outside Git Bash (which is what puts `uname` within reach of `wt.sh`); and a PATH-scoped lookup, because an unscoped `where` searches the current directory first — for a hook that's the user's project, so a `git.exe` committed to a repo would otherwise choose the bash every event runs. The lookup is spelled `"%SystemRoot%\System32\where.exe" "$PATH:git.exe"` on both counts: the `$PATH:` prefix scopes what is searched, and the absolute path to `where.exe` closes the same surface one level up — `where` is `System32\where.exe` rather than a cmd built-in, so cmd resolves that bare name from the current directory too. `wt.sh` now clears `WT` before its branches. On Windows all of them can be skipped (neither `git-wt.exe` nor `wt` on PATH), and a hook is handed the caller's whole environment, so an inherited `WT` was what the final `command -v "$WT"` check accepted and ran. The Windows commands brace the plugin root as `${PLUGIN_ROOT}` because Codex substitutes only that form textually, before the shell runs; the unbraced `$PLUGIN_ROOT` the Unix commands use survives to `/bin/sh`, and `cmd.exe` would pass it through literally. The tail is `|| exit /b 0`, the cmd.exe spelling of the Unix `|| true`: a marker is decoration, and a nonzero exit is what raises the banner. `.gitattributes` pins `plugins/worktrunk/hooks/*.cmd` to a CRLF checkout, since cmd.exe resolves a `goto` label by seeking through the file and can fail that search on an LF-only batch file. Two doc changes ride along. `plugins/worktrunk/CLAUDE.md` records why every hook carries `commandWindows`, the shim's resolution order and the two lookup-scoping decisions, and why `SessionEnd` keeps `timeout: 3` (Codex's ceiling for that event, so the longer Windows chain has no more budget to ask for). And because the CHANGELOG entry opens an `## Unreleased` section, `.claude/skills/release/SKILL.md` step 9 now says to rename that heading at release time rather than insert a new one above it — otherwise the release ships a stale `## Unreleased`. ## Testing Six tests, all new: - `test_codex_hooks_carry_windows_commands` (all platforms) — pins that every Codex command hook has a `commandWindows`, that it names neither `bash` nor bare `wt`, that it calls the shim, and that the two `PLUGIN_ROOT` spellings stay on their respective sides. Written first: it failed on the manifest as shipped, with the exact hook command from the report. - `test_codex_windows_hook_commands_set_the_marker` (Windows leg of CI) — runs the real `commandWindows` the way Codex spawns it, reproducing both steps: the `${PLUGIN_ROOT}` substitution and `cmd.exe /C "<command>"` with the command line as a single quoted raw argument. It asserts `UserPromptSubmit` stores 🤖, `Stop` replaces it with 💬, `SessionEnd` clears it, and that a hook which cannot find worktrunk still exits 0. PATH is pinned to the shape a default Git for Windows install produces — `Git\cmd` and nothing else from the install — so the run covers cmd.exe's quote handling, the shim's search for bash, `wt.sh` running under the bash it picks, and the emoji surviving both hops. - `test_wt_sh_ignores_an_inherited_wt` (Windows leg) — points `WT` at a real worktrunk, spelled the way bash can run it, on a PATH where `wt.sh` finds none itself, and pins that the inherited value is not what runs. - `test_shim_ignores_a_git_in_the_current_directory` (Windows leg) — plants an unrunnable `git.exe` in the hook's current directory plus the `bash.exe` the shim would derive from it, puts a real Git and worktrunk on the pinned PATH, and asserts the shim prints a version line — which it could not do had it taken the decoy. - `test_shim_ignores_a_where_in_the_current_directory` (Windows leg) — the same question one level up: a `where.bat` in the hook's current directory names a decoy Git install whose `bin\bash.exe` exists, so an unscoped `where` would reach the derive branch and leave `BASH` set and unrunnable rather than falling through to a real install. A separate probe pins the premise — that the planted `where` really does shadow `System32\where.exe` — so the test cannot go green because the decoy was never consulted. - `test_shim_derives_bash_from_the_git_on_path` (Windows leg) — points `ProgramFiles` and `LOCALAPPDATA` at an empty directory, which leaves the derive branch as the only route to a bash. The two tests above go red only when the shim picks the *wrong* bash; this one goes red when the lookup finds nothing, so it is what observes that `"%SystemRoot%\System32\where.exe" "$PATH:git.exe"` survives the quoting `for /f` wraps it in. <details><summary>What this does not verify</summary> Nothing here drives a real Codex session on Windows, so the end-to-end claim — that Codex selects `commandWindows` and that the banner stops — rests on reading `codex-rs/hooks/src/engine/{discovery,command_runner}.rs` rather than on observation. What CI does exercise is the command string itself, executed the way that source says Codex executes it. The shim's two standard-install fallbacks (`%ProgramFiles%\Git`, `%LOCALAPPDATA%\Programs\Git`) are unexercised by CI. Every test that reaches a bash takes the derive branch above them, and the one test that touches the fallbacks empties them to force that branch rather than to exercise them; they share the whole tail with it. Two adjacent things are deliberately left alone, as separate concerns: the Gemini hooks at the repo-root `hooks/hooks.json` use the same bare `bash` (Gemini's Windows hook execution isn't established here), and Claude's `hooks/hooks.json` is unaffected because Claude Code runs hook commands through Git Bash on Windows. </details> --- Closes #4007 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
fa57cb63a4 |
Document copy-ignored's disk-space benefit (#4022)
`wt step copy-ignored` reflinks the files it copies, so a new worktree's
`target/` shares disk blocks with the primary worktree's until something
writes to them. That was documented only under a "Performance" heading,
next to a table of times, where it read as an explanation of why the
copy is fast rather than a benefit in its own right. Nothing quantified
the disk saving, and the homepage bullet ("Copy build caches — skip cold
starts…") sold the time and not the space.
The saving is large and it survives real use: cargo writes new files for
changed crates rather than rewriting dependency rlibs, so the bulk of
`target/` stays shared as a worktree is built in. Measured across 56
worktrees of this repo on one machine, `du` reports 2.59 TB where the
disk holds 0.71 TB.
Changes:
- Rename the `copy-ignored` "Performance" section to "Copy-on-write",
add a Disk column to the existing table, and name the filesystems that
support reflink along with what happens on ext4 and NTFS. Nothing linked
to the old `#performance` anchor.
- Add an FAQ question, "How much disk do worktrees use?", for the
fleet-level number — what someone weighing a many-worktree workflow
actually wants to know, and a question the FAQ didn't answer.
- Rewrite the homepage bullet as "Share build caches" — "Copy" named the
thing the feature avoids doing — and state both benefits: `target/`,
`node_modules/`, etc reach ten worktrees without being built or copied.
- Drop the copy-on-write bullet from the Claude Code comparison. Claude
Code ships compiled with Bun, whose `fs.copyFile` uses `clonefile()` on
macOS, so it isn't a difference there: on a 2 GB file, Bun's
`copyFileSync` consumed 6 MiB against Node's 2111 MiB. The remaining
bullets carry the real differences.
The filesystem scope is stated wherever a claim is made, since reflink
needs APFS, btrfs, XFS, or ReFS; ext4 and NTFS fall back to a full copy.
Follow-up, not in this PR: `reflink_or_copy` returns `None` on reflink
and `Some(bytes)` on fallback, and `src/copy.rs:125` matches `Ok(_)` and
discards it. So a user on ext4 gets a full copy, is told `Copied N files
· 29.5 GB`, and has no way to learn which they got. The docs now state
the caveat per filesystem; the command could state it per machine.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
24d1a8d15e |
docs(hook): say that post-* hooks from the two sources run concurrently (#4020)
`wt hook --help` said project hooks run "after user hooks". That holds for `pre-*`, which run as one blocking pipeline, and not for `post-*`, where each source gets its own detached pipeline and both start at once. Nothing else in the docs said so, so a hook written against the stated order looked supported when it wasn't — two `post-merge` hooks that each run `git pull` in the merge destination append to one another's `FETCH_HEAD`, both die with `fatal: Cannot rebase onto multiple branches`, and `wt merge` reports success with the target unpushed. This corrects the table and adds a paragraph saying what each kind actually does, including that commands which depend on each other belong in one source. <details> <summary>Why the docs moved rather than the code</summary> Sequencing the pipelines was the other way to close the gap, and this branch tried it first. Two things break: `post-start` is documented for dev servers and `wt step tether`. With a user `post-start` of `sleep 300` standing in for one, the project's `post-start` marker lands within 6s on `main` and never lands when the pipelines are chained. A batch also spans two worktrees: `post-commit` anchors on the invoking worktree, `post-remove`/`post-switch`/`post-merge` on the destination. The invoking worktree is removed before the hooks run, so chaining drops everything behind that head. On `wt merge main --no-squash` with a dirty worktree and all four hooks configured, `main` runs post-remove, post-switch and post-merge; the chained version ran only post-switch. The race the sequencing was aimed at is real but narrower than the cure: it needs two hooks touching one worktree's git state, which the docs now tell users to avoid by keeping dependent commands in one source. `CLAUDE.md` records the constraint so the fix isn't re-attempted. </details> > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
474f93a007 |
Replace config update --print with --output (#4021)
`wt config update` now uses a destination-based output interface: `--output <path>` atomically writes the migration artifact, while `--output=-` emits the same bytes to stdout. This replaces `--print`, keeps output mode read-only, and includes project-config migrations when invoked from a linked worktree. Relative destinations honor the global `-C` directory. When there is nothing to migrate, stdout stays silent and an existing file destination remains untouched. If both user and project configs need migration, stdout emits a labeled inspection artifact while file output fails before writing. Help text also notes that output artifacts omit legacy `approved-commands`; the in-place mode remains responsible for moving those entries to `approvals.toml`. Integration coverage exercises file replacement, preservation and write failures, multi-config rejection, stdout piping and broken consumers, clean configs, linked worktrees, source-file preservation, help text, and rejection of the removed flag. The full pre-merge gate passed all 4,770 tests, targeted instrumentation covers both file-error branches, and the production docs build passed all 16 built-site and browser tests. > _This was written by Codex on behalf of max-sixty_ |
||
|
|
7d08f6775f |
Add Pi activity tracking plugin (#3594)
## Summary - add `wt config plugins pi install|uninstall` - install a profile-aware Pi / oh-my-pi hook with atomic writes - map Pi lifecycle events to Worktrunk working, waiting, and cleared activity markers - honor Pi's agent-directory, config-directory, and profile environment variables - cover profile paths, explicit directory overrides, uninstall behavior, and CLI output snapshots ## Validation - `cargo fmt --check` — passed - `cargo test --test integration test_pi_ -- --nocapture` — 3 passed - the same snapshot tests passed again without `INSTA_UPDATE` - `cargo clippy --bin wt -- -D warnings` — passed Closes #3571 --------- Signed-off-by: Aditya Datta <crazyme07071996@gmail.com> |
||
|
|
8e420cb8c4 |
Load the OpenCode plugin under OpenCode 2 (#4018)
## Problem `wt config plugins opencode install` writes a plugin OpenCode 2 silently ignores, reported in #4014. OpenCode 2's loader decodes a plugin module's default export against a schema that accepts `{ id, effect }` or `{ id, setup }` — a bare default-exported function, which is what this plugin has always been, fails the decode and the load is dropped without a message ([`packages/core/src/plugin/module.ts#L60`](https://github.com/sst/opencode/blob/b2cecc6350d377c382e1ec32ee66ec63ad68f715/packages/core/src/plugin/module.ts#L60) on the `beta` branch, which is where the OpenCode 2 prereleases come from — `dev` is the 1.18.x line). The plugin also ran `wt` through Bun's `$`, which the host only supplies when it is itself running under Bun. ## Solution One file, both runtimes: the default export is now `{ id, setup, server }`, and `wt` is spawned with `node:child_process` instead of Bun's shell. OpenCode 2 reads `setup` and subscribes to session events; OpenCode 1.16+ reads `server` and gets exactly the hooks it has today. Each version ignores the other's key, so the install path, filename, and marker behavior are all unchanged. Two details in the `setup` arm that differ from the port in the issue's thread — both from reading the shipped `@opencode-ai/plugin@beta` types: `session.status` carries `status.type ∈ {idle, busy, retry}` and `session.idle` is annotated deprecated, so the status is read rather than the event name; and `session.status` with an `idle` status sets 💬 rather than 🤖. **The part worth a maintainer's call**: this keeps the OpenCode 1 shape rather than switching cleanly to v2 alone. OpenCode 2 is still prerelease (`npm dist-tags` for `@opencode-ai/plugin`: `latest` 1.18.29, `beta` 0.0.0-beta-19192), so a v2-only file would make every current stable install fail with `Plugin … must default export an object with server()`. The cost is the `server` arm plus its two local types — say the word and I will cut it to `{ id, setup }`. Either way OpenCode below 1.14.19 loses the plugin: that is where `readV1Plugin` — the function that admits an object-shaped default export — first appears, and older releases accept only the bare function. 1.16 is the floor worth documenting, though, because it is where the host began filtering events to each plugin instance's directory ([v1.16.0](https://github.com/sst/opencode/blob/6cb74317a6efacd656483cb0489d8e7e3701c12e/packages/opencode/src/plugin/index.ts#L258-L259)); [v1.15.0](https://github.com/sst/opencode/blob/2662a4f955e563fd22cd5c4873ca350d21745275/packages/opencode/src/plugin/index.ts#L246) fans every bus event to every instance, so on 1.14.19–1.15.x the marker follows the worktree the instance was created for rather than the session that is active — the same behavior today's plugin has, not a regression. ## Verification `cargo test --test integration test_docs_are_in_sync`, the twelve `opencode` integration tests, and `packaged_assets` pass. Beyond that I ran the new file through both loaders' own logic under `node --experimental-strip-types`, with a stub `wt` on `PATH` recording its arguments. <details><summary>Loader checks and recorded marker calls</summary> Decoding the real module with the exact schema OpenCode 2 uses (`effect@4.0.0-rc.112`, the version its plugin package depends on), and with OpenCode 1's [`readV1Plugin`](https://github.com/sst/opencode/blob/337fd144d2ba144743368f78d9579a99cce175bd/packages/opencode/src/plugin/shared.ts#L272) detect-mode logic: ``` OpenCode 2 module decode : ok, id=worktrunk OpenCode 1 readV1Plugin : ok, id=worktrunk v2 setup returned cleanup: true v2 setup on a 1.18 context: bails, no throw ``` The last line covers a case worth calling out: OpenCode 1.18 also carries a `{ id, setup }` loader, but the context it passes has neither `location` nor `event`. `setup` returns early there instead of throwing, and `server` drives the marker. Feeding a fake event stream, the commands the plugin actually issued: ``` v2: set 🤖 (status busy) set 🤖 (status retry) set 💬 (status idle) — skipped, event location is another worktree — set 💬 (session.idle) clear (session.deleted) clear (cleanup) v1: set 🤖 / set 💬 / clear (unchanged from today) ``` What I could not check from CI is the plugin running inside a real OpenCode 2 session — @pragmaticivan, if you are still up for testing, `wt config plugins opencode install` off this branch should behave the same as your local port. Install location is unchanged and confirmed on `beta`: plugins are still discovered as `.ts`/`.js` files under `plugin/`, `plugins/` in each config directory ([`source-directory.ts#L7`](https://github.com/sst/opencode/blob/b2cecc6350d377c382e1ec32ee66ec63ad68f715/packages/core/src/plugin/source-directory.ts#L7)). </details> Closes #4014 --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
29a611b7c4 | fix(config): marker set/clear no-op outside a git repository (#3981) | ||
|
|
3f87fec5a7 |
Leave the branch template variable unset in a detached worktree (#4010)
## Problem
In a detached worktree, `CommandContext::branch_or_head` substituted the
literal `HEAD` for `{{ branch }}`. `HEAD` is a non-empty string git
happily resolves as a ref, so every guard written around `branch` passed
and the command ran against the wrong thing — the reported case ended in
`git push origin --delete HEAD`. It also disagreed with `wt list
--format=json`, which reports `branch: null` for the same worktree, and
with the hook docs, which say undefined variables error so a template
can guard them.
## Solution
`branch` is now absent in a detached worktree rather than falling back.
`{% if branch %}` guards it the way the docs already prescribe for
`upstream`; an unguarded `{{ branch }}` is an undefined-variable error
naming the template and listing the variables that *are* in scope. This
is the first of the two options in #4009 — the one the reporter picked.
The removal path needed the same treatment on its own:
`spawn_hooks_after_remove` took the removed branch as `&str` and both
detached call sites passed the literal `HEAD`, which
`PostRemoveContext::extra_vars` then applied *after* the base context —
so `post-remove` still saw `branch = HEAD` even with the base fix in
place. It takes `Option<&str>` now. That is the hook where the issue's
own command belongs, so it's covered by its own regression test
(`test_user_post_remove_branch_unset_in_detached_worktree`, which
reproduces `branch=[HEAD]` without the change).
Riding along:
- The `base` and `target` branch names derived from the current branch
follow `branch` and stay unset too — both for a **manual** `wt hook
<type>` and for the `pre-switch` hooks `wt switch` fires, which
previously rendered `base` as an empty string. The directional *path*
vars (`base_worktree_path`, `target_worktree_path`) still apply — the
worktree exists whether or not it is on a branch.
- `vars` is now inserted as the **empty map** when `branch` is absent,
not skipped. Per-branch vars are keyed by branch, so a detached worktree
has none either way (`wt config state vars set` can't even write there —
it goes through `require_current_branch`), but keeping the object
defined means `{{ vars.key | default('x') }}` still renders under
SemiStrict instead of erroring on an undefined `vars`. Same reason
`list::custom_columns` injects an empty map for a branchless row.
- The verbose variables table rendered a detached `branch` as `(unused)`
under `VarScope::Referenced` — the label that means "the scope gate
saved us the work", which is wrong for a cheap var computed regardless.
`ALWAYS_COMPUTED_VARS` in `expansion.rs` marks it `(unset)`, the label
for a var the operation genuinely couldn't supply.
- The `hook` long-help sentence about unset `base` / `target` scoped
them to a manual `wt hook`; both are now operation-driven cases too
(`base` in a `pre-switch` from a detached worktree, `target` in a
removal that lands in one), so it's worded generically and the three doc
mirrors are regenerated.
- The `hook` long-help JSON-context example read `ctx['branch']`
unguarded — the same unguarded case in Python, which now raises
`KeyError`. It uses `ctx.get('branch', '')` and the section says why.
- `target` — the branch the user lands on after a removal — followed the
same rule on review: `PostRemoveContext::new` built it with
`unwrap_or_default()`, so a **detached primary worktree** handed
`post-remove` an empty string and made `wt -v` print `target = ` instead
of `target = (unset)`. It's `Option<String>` now, pushed only when there
is one. `commit` / `short_commit` keep their `""` shape — they predate
this and `PostRemoveContext::new` documents the choice. The `pre-remove`
half of the same removal built its extra vars by hand and had the same
`unwrap_or_default()`; it goes through `TemplateVars::with_target_opt` +
`as_extra_vars` now — the builder `wt hook pre-remove` already used — so
both hooks of one removal agree.
- The `HEAD` literal survives in exactly one place: the background
pipeline's **log file name** (`spawn_hook_pipeline_quiet`), which needs
some string and never reaches a template.
`branch_or_head` had no callers left, so it's deleted.
`TemplateVars::with_base` takes `Option<&str>` and a new
`with_target_opt` sits beside `with_target`, so the optional-branch call
sites keep the POSIX path conversion in the builder rather than
re-inlining it.
## Behavior change worth a look
This is deliberate — it's what the issue asks for — but it turns
previously-working shapes into errors, each pinned by a test that this
PR updates rather than deletes:
- `wt step for-each -- echo '{{ branch }}'` now fails at the detached
worktree instead of printing `HEAD`, and (as with any undefined
variable) the loop stops there. Not a new failure mode: `{{ upstream }}`
already behaved this way at the first non-tracking worktree.
`test_for_each_detached_branch_variable_unguarded` snapshots it.
- A project `pre-remove` or `post-remove` hook that references `{{
branch }}` unguarded now blocks `wt remove` on a detached worktree until
it's guarded (or `--no-hooks`).
`test_pre_remove_hook_branch_expansion_detached_head` previously
asserted `branch=HEAD`; it now uses the guarded form and asserts
`branch=`.
- A `pre-switch` hook run from a detached worktree gets no `base` rather
than an empty one. An unguarded `{{ base }}` errors where it used to
render nothing.
If any of these should instead render an empty string rather than error,
that's a different fix — say the word and I'll redo it that way.
## Testing
`test_alias_branch_unset_in_detached_worktree`
(`tests/integration_tests/step_alias.rs`) and
`test_user_post_remove_branch_unset_in_detached_worktree`
(`tests/integration_tests/user_hooks.rs`) are the reproductions: both
fail with `HEAD` before their respective fixes and pass now.
`test_user_remove_hooks_target_unset_with_detached_primary_worktree`
(both `pre-remove` and `post-remove` of one removal) and
`test_user_pre_switch_base_unset_in_detached_worktree` pin the `target`
and `base` cases; both use `{% if x is defined %}` rather than `{% if x
%}`, since an empty string and an absent var are indistinguishable under
the plain guard, and each half fails on its pre-fix shape.
`cargo run -- hook pre-merge --yes` is green apart from three failures
that reproduce with these changes stashed, on this branch, in the same
sandbox — `test_copy_ignored_preserves_file_executable_permissions`
(expects 0644, gets 0664 under the runner's umask 002) plus
`test_powershell_skipped_when_installed_no_profile` and
`test_nushell_install_target_is_a_vendor_autoload_dir` (the sandbox
home's shell state). Clippy `--all-targets --all-features` and `cargo
fmt --check` are clean.
<details><summary>Manual check against the issue's repro</summary>
```console
$ git worktree add --detach ../repo.scratch HEAD && cd ../repo.scratch
$ wt -v guarded # probe = 'echo "[{% if branch %}{{ branch }}{% endif %}]"'
○ guarded template variables:
branch = (unset)
worktree_path = /tmp/…/repo.scratch
…
[]
$ wt probe # probe = 'echo "[{{ branch }}]"'
✗ Failed to expand probe: undefined value @ line 1
echo "[{{ branch }}]"
↳ Available variables: args, cwd, main_worktree, repo, repo_path, repo_root, worktree, worktree_name, worktree_path
$ cd ../repo && wt probe # unchanged on a branch
[main]
```
</details>
---
Closes #4009 — automated triage
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
|
||
|
|
51fd3b2b7e |
docs(config): name the bare repo's own location in the worktree-path example (#4006)
## Problem
The `worktree-path` examples in the user-config guide are introduced as
being **"for repo at `~/code/myproject`"**, and every example states the
path it produces. For all but one, that arithmetic checks out. The
bare-repository example doesn't:
```toml
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
```
Heading claimed `~/code/myproject/feature-auth`. With `repo_path` at
`~/code/myproject` as the section says, `{{ repo_path
}}/../feature-auth` resolves to `~/code/feature-auth` — one directory up
from the stated result.
## Fix
The template is right; the heading silently switched the repo's location
without saying so. `{{ repo_path }}` for a bare repo is the bare
directory itself (as the variable list a few lines above states), so the
claimed result holds only when that directory is a hidden child — the
`myproject/.git` layout that [tips-patterns.md
documents](https://github.com/max-sixty/worktrunk/blob/main/docs/src/content/docs/tips-patterns.md#bare-repository-layout),
and that `wt switch`'s bare-repo offer writes this exact template for.
So the heading now names it:
> Bare repository cloned to `~/code/myproject/.git`
(`~/code/myproject/feature-auth`):
Edited in `src/cli/mod.rs` (the primary source); the four generated
mirrors and two `--help` snapshots are regenerated output.
## Testing
No regression test — this is a documentation string with no behavior
attached. The generated mirrors are pinned by the existing sync tests,
which is what caught them here:
- `cargo test --test integration readme_sync` — 18 passed (regenerates
`dev/config.example.toml`, `docs/src/content/docs/config.md`, and both
`skills/.../reference/config.md` mirrors).
- `cargo insta test --accept --test integration -- test_help` — 47
passed (`help_config_create`, `help_config_long`).
- `cargo fmt --check` — clean.
- `cargo test --test integration` — 2048 passed, 1 failed.
<details><summary>The one integration failure is a sandbox artifact, not
a regression</summary>
`step_copy_ignored::test_copy_ignored_preserves_file_executable_permissions`
expects `0644` and gets `0664`. The tend sandbox runs with `umask 0002`
(group-writable) rather than the `0022` the test assumes.
Confirmed unrelated: it reproduces identically with this branch's
changes stashed, i.e. on the merge base. Both `ci` and `coverage` on
`main` are green at `2026-09-03T10:17:26Z`. This diff touches only doc
strings and snapshot files and cannot reach file-permission code.
</details>
<details><summary>Checked against the in-flight docs PRs</summary>
#4000, #3999, and #3998 each touch the same five files (`src/cli/mod.rs`
plus the four config mirrors), so I checked for the duplication that
sank #4001. None of them edits the `worktree-path` examples region, and
`git merge-tree` against each reports a clean merge with this branch.
</details>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
|
||
|
|
ce57d2d997 |
Name the unconfigured source when a hook filter matches nothing (#3997)
A source filter with no command name (`wt hook pre-merge user:`) reported `No command named user:` when that source configured no hooks — a message about a name the user never gave. `HookSourceNotConfigured` says what is true and points at the source that does have hooks, when one does. The rest corrects claims the hook and step reference pages had drifted from: `--foreground` for a post-hook, `pre-commit` running before every Worktrunk commit rather than only the merge's, which commands accept `--no-hooks`, that a preview leaves a whole `vars` expression alone including its filters, the `wt hook show` approval glyph, `wt step commit --branch`, that hooks fire on `wt step commit` and `wt step squash`, `wt step copy-ignored`'s `--from` / `--to` and its primary-worktree default, and that `wt step prune` removes branches as well as worktrees with `--min-age` guarding both. Two `worktree-path` recipes give way to a link to the config page that owns them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb |
||
|
|
de0052d8db |
Release v0.76.0 (#3983)
Cuts 0.76.0. `cargo semver-checks` fails ten lints, so the bump is minor. ## Changelog filing fix Five entries had been appended to the already-tagged `## 0.75.0` section after v0.75.0 shipped (#3977/#2860, #3949 ×2, #3959, #3945). They describe changes that ship *here*, and GitHub's 0.75.0 release notes — built from the tag — never showed them. They are moved into `## 0.76.0` and rewritten to the length ceiling; `## 0.75.0` is restored byte-identical to `git show v0.75.0:CHANGELOG.md`. The mechanism is systemic: a PR appends under whatever heading is currently top, so every post-release PR lands in the shipped section until the next release opens a new one. ## `--execute` template guidance `#3977` switched `--execute` expansion to `ShellEscapeMode::Literal`, which is correct for an argv model. But the migration that v0.53.0–v0.75.0's deprecation warning *printed* — `-x sh -- -c '<old body>'` — splices a template variable into text `sh` re-parses, so a value with spaces word-splits where the old POSIX-escaped model kept it intact. Worst case is `rm -rf` against unintended paths. `--execute`'s help now documents passing the variable as a separate argument and referencing it positionally. `extending.md` already modelled that form, so no recipe changed. ## Validation - `nightly` on the cut-from tip (`baf161bf6`): all 14 jobs green — [run 33538866753](https://github.com/max-sixty/worktrunk/actions/runs/33538866753) - `wt hook pre-merge --yes`: 4713 passed - Data-loss surface reviewed across the 45-commit diff by four independent finders; adjudication in the release thread. No new destructive path — `#3977` removes one, deleting the EXEC directive file from all five shell wrappers. > _This was written by Claude Code on behalf of max-sixty_ |
||
|
|
baf161bf60 |
Run switch --execute as literal argv (#3977)
## Summary - Treat `wt switch -x` as one program plus arguments that bypass Worktrunk shell parsing; program lookup and argument decoding use native operating-system behavior. - Launch the program from `wt` in the selected worktree, preserving terminal access, signals, and exit status. - Remove the shell exec directive, shell-specific escaping, and implicit `sh` dependency. Shell wrappers now carry only the directory change. - Make the Nushell wrapper recognize clustered execute flags such as `-cx`, stop scanning at `--`, and warn when a retired exec-file wrapper buffers stdout away from the terminal. Shell syntax remains explicit: `-x sh -- -c 'code . && test -f Cargo.toml'`. On Windows, shell shims need their extension (`-x code.cmd`) or an explicit shell such as `-x cmd.exe -- /C code`. This completes the argv cutover proposed in #2860 and removes the remaining Nushell-on-Windows problem in #3944. It adds no PATHEXT lookup, custom Windows quoting, or PowerShell launcher path, and still removes 597 lines from `src` and `templates` on net. ## Validation - `cargo run -- hook pre-merge --yes` - 4,712 tests passed; one skipped - Formatting, clippy, docs, doctests, lockfile, and snapshot checks passed Thanks @omgreenfield for testing the migration path in #2860. Closes #2860 Closes #3944 > _This was written by Codex on behalf of @max-sixty_ |
||
|
|
6237f5b6eb |
docs(readme): refresh status stamp to September 2026 (#3979)
The README's intro blockquote still opened with **August 2026**; today is 2026-09-01, so the stamp is a month stale. This bumps it to **September 2026** — the same monthly refresh as #3686 (August), #3347 (July), and #2972 (June). Line 16 sits above the first `<!-- AUTO-GENERATED -->` marker (line 28), so it is a hand-edited primary rather than generated content. It has two mirrors, which behave differently: `skills/worktrunk/reference/README.md` is a symlink to the root `README.md` and picks the change up for free, while `plugins/worktrunk/skills/worktrunk/reference/README.md` is a generated *file copy* and needs its own sync — that second commit is here because `test_docs_are_in_sync` caught the stale copy on the first push. No regression test: the stamp is prose with no behavioral surface, and its correctness is time-dependent rather than assertable. <details><summary>Nightly sweep context</summary> Found by the rolling survey (bucket 5/28) together with the README date check in the repo's `running-tend` skill. The rest of the sweep was clean: - **tend config** — `tend check` all PASS; bot PAT carries every required scope. - **Conflicts** — #3129 (bot) and #3966/#3967/#3978 (Dependabot) all test-merge clean against `main` via `git merge-tree`; no deferral comments to clear. - **Workflow regen** — `uvx tend@latest init` produced no diff; the committed workflows are already at tend 0.1.24. - **Recent commits** — the 7 commits in the last 24h reviewed; no findings. The `for_action_branchless` → `for_action_loading_config` rename in #3970 left no stale references, and #3976's `append_call_line` change carries its own regression test. - **Survey** — `src/cache.rs`, `src/config/user/path.rs`, `src/config/user/sections.rs`, `src/styling/line.rs`, `docs/src/content/docs/claude-code.md`, the two `.config/clawpatch/features/*.json` files (every referenced path still resolves), and the skill/plugin pages. No findings. </details> --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
6a96ddfd7d |
Pin Claude marker hooks to launch worktree (#3956)
Pins Claude activity-marker updates and cleanup to `CLAUDE_PROJECT_DIR`, so a shell `cd` cannot retarget them to another repository. Claude markers deliberately remain on the launch worktree after `EnterWorktree`, and sessions launched outside a repository have no marker. Codex and Gemini remain cwd-based until their hook harnesses expose a stable session project directory. The hook lifecycle was not exercised inside a live Claude session; tests cover the manifest wiring and cross-shell parsing. Part of #3921 > _This was written by Codex on behalf of max-sixty_ |
||
|
|
59211c6e64 |
docs(hook): fix the --base example in the pre-start upstream snippet (#3955)
`wt hook --help`'s `pre-start` example illustrated the `upstream` template variable with `wt switch --create feature origin/feature`, which the CLI rejects — `wt switch` takes no positional base: ```console $ wt switch --create feature origin/feature error: unexpected argument 'origin/feature' found ``` Now `--base origin/feature`. The template snippet it annotates is unaffected. > _This was written by Claude Code on behalf of max-sixty_ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
541f6d204d |
Revert "feat(list): add [list] sort for configurable row order" (#3952)
(not determinate, but want to rethink whether this is useful) |
||
|
|
1b4c492ee8 |
fix(switch): decide branch tracking for --create, whatever the git config says (#3950)
## Problem `wt switch --create <name> --base origin/<branch>` must not leave the new branch tracking a differently-named base: under `push.default = upstream` a bare `git push` then pushes the new work onto the base branch ([#713](https://github.com/max-sixty/worktrunk/issues/713)). Since #3913 that came from `-c branch.autoSetupMerge=simple`, injected only when the user had not set the key. So the outcome still depended on the user's git config, and two of git's five values reach a wrong answer: `true` and `always` track a differently-named base (the #713 footgun is still live), while `false` and `inherit` deny a *same*-named branch the tracking that is the point of it — including the DWIM `wt switch feature` from `origin/feature`, which the docs promise is a tracking branch. ## Solution Force the `-c` instead of defaulting it, on every `git worktree add` `wt switch` runs. `-c` outranks every config file, so one rule now decides the upstream whatever the user has configured: a new branch tracks the remote branch it starts from only when the two share a name. DWIM always shares it, so it always tracks; `--create` from a differently-named base gets no upstream. Tests: `test_switch_create_from_remote_base_upstream` becomes a matrix over all six `branch.autoSetupMerge` values (unset, simple, false, inherit, true, always) × three base spellings (`origin/release`, the bare `release` that resolves to it, and `refs/remotes/origin/staging`); `test_switch_dwim_from_remote_tracks` pins the DWIM half over the values that used to decline. ## Why not `--track` / `--no-track` The obvious alternative is for `wt` to pick git's explicit flags from a name comparison of its own. I built that first; it passed the full suite, and it is wrong twice over. <details> <summary>Three reproduced defects in the explicit-flags version</summary> **The predicate is wrong.** `strip_remote_prefix` splits `<remote>/<branch>` at the first slash, but git maps a remote-tracking ref back to its branch through the *fetch refspec*. With a remote named `team/fork`, the two disagree and the verdict inverts both ways: ```console $ git remote add team/fork <url> && git fetch team/fork $ wt switch --create fork/release --base team/fork/release $ git config branch.fork/release.merge refs/heads/release ``` A bare `git push` under `push.default = upstream` then lands on `release` — #713, reintroduced. The same base with `--create release` got *no* upstream, the opposite error. A refspec renaming into a sub-namespace (`+refs/heads/*:refs/remotes/origin/mirror/*`) does the same with no unusual remote name. **`--track` is a hard demand where `simple` is best-effort.** In a single-branch clone holding a hand-fetched ref, it fails the whole command — after the branch name has already been taken: ```console $ git clone --single-branch -b main <url> && git fetch origin release:refs/remotes/origin/release $ wt switch --create release --base origin/release fatal: cannot set up tracking information; starting point 'origin/release' is not a branch ``` That is the matching-name case, i.e. exactly what the feature exists for, and it is the same "fails outright" class #3913 had just removed. **Qualified spellings lose tracking.** `--base refs/remotes/origin/release` and `--base remotes/origin/release` name the same ref as `origin/release` but don't match `short_name`, so they got `--no-track`. </details> All three come from `wt` computing the name match itself, which it cannot do correctly — only git knows the refspec mapping. So `wt` decides the *rule* and git applies it. The first two are now regression tests (`test_switch_create_base_on_remote_with_slash`, `test_switch_create_base_outside_fetch_refspec`), so a future simplification to `--track` fails the suite rather than shipping. ## Verification Measured end-to-end on scratch clones across all six `branch.autoSetupMerge` values: a differing name gets no upstream and a matching name tracks, identically in every one. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9c839a194 |
feat(list): add [list] sort for configurable row order (#3933)
Row order in `wt list` and the `wt switch` picker was fixed — current worktree first, primary second, then committer date descending — while `[list] columns` already let a user pick and order the columns. This adds `[list] sort`, a list of column names (most significant first, `-` prefix for descending) that orders the rows, restricted to the columns whose value is known before the table paints, per the maintainer's call on #3922. ```toml [list] columns = ["path", "branch", "status"] sort = ["path"] # the reporter's case: the table mirrors the directory layout # sort = ["-age"] # oldest commit first # sort = ["branch", "-age"] ``` Verified with `cargo run -- hook pre-merge --yes`: 4725/4728 passed, the three failures being sandbox-environment artifacts that fail identically on the base commit (a PowerShell profile and a Nushell autoload dir this runner does have, and a `0664` umask). New coverage is 7 unit tests in `src/commands/list/sort.rs` and 7 integration tests in `tests/integration_tests/list_config.rs`. Closes #3922 <details><summary>Design decisions</summary> **Only skeleton-time columns are sortable** — `branch`, `path`, `commit`, `age`, `message`, i.e. exactly the built-ins with no background task. The rest (`status`, `ci`, `upstream`, the diff columns, custom columns) stream in behind the first frame, so ordering on one would mean either holding the table for a network round trip or reordering rows under the cursor, which the progressive table doesn't do. Naming one is an error that says why rather than a silently ignored setting. `test_sortable_keys_are_exactly_the_task_free_columns` pins the correspondence to `ColumnKind::required_tasks`, so a new task-free column can't quietly become unsortable (or a streamed one sortable). **A spec replaces the default order outright, pinned prefix included.** `sort = ["path"]` exists to make the table read in path order; keeping the current and primary worktrees pinned to the top would defeat exactly that. Newest-commit-first survives as the final tiebreak, which means the empty spec and the fallback are one code path — and rows a spec can't separate keep the order they have today, e.g. branch-only rows under `sort = ["path"]`, since only worktrees have a path. **`age` ascending is newest first.** The column counts up from the commit date, so the smallest age is the newest commit — the same direction as the default order — and `-age` puts the oldest on top. **Rows sort within their group, never across it**: worktrees, then branch-only rows, then remote rows, as before. **`--format json` follows `sort`**, unlike `columns`. The reasons `columns` is excluded are the every-field contract and not letting a display setting decide whether a machine-readable call reaches a forge (#3787); reordering an array narrows no payload and adds no fetch, so neither applies. **A bad key aborts `wt list` and degrades the picker**, the same fork `[list] columns` takes — the picker can't surface an abort mid-render, so it stashes a warning and falls back to the default order. </details> <details><summary>Changes</summary> - `src/commands/list/sort.rs` (new) — `SortKey`/`SortTerm`, `parse_sort_spec`, and the `compare` comparator over per-row `SortFacts`. - `src/commands/list/collect/mod.rs` — parse the spec before the sort, thread it through `sort_worktrees_with_cache` and the new `sort_branch_rows` (which replaces the generic `sort_by_timestamp_desc_with_cache`; both call sites passed the same `Vec<(String, String)>`). Facts are precomputed per row, so the commit-details lookup stays one per row rather than one per comparison. - `src/config/user/sections.rs` — `ListConfig::sort`, merging wholesale like `columns` (a spec is an ordering; merging terms across layers would invent an order neither layer asked for). - `src/cli/mod.rs` — a "Row order" section under `wt config`'s `[list]` docs; the mirrors under `docs/`, `skills/`, `plugins/`, `dev/config.example.toml`, and the help snapshots are regenerated. </details> --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
48b8b3802a |
Stabilize mature interfaces and remove retired config (#3949)
Marks the mature `wt step eval`, `wt step for-each`, `wt step prune`, LLM summary, config-state vars, and commit-template append interfaces as stable. Removes compatibility for the long-deprecated `template-file` and `squash-template-file` settings, and retires the special migration for the already-ignored `switch.picker.timeout-ms`. These keys now follow the ordinary unknown-field path; the troubleshooting guide explains how to move file contents into inline templates. The change also removes the redundant project CI-platform accessor. Validation: `cargo run -- hook pre-merge --yes` (4,710 tests passed; one skipped), plus a production documentation build. > _This was written by Codex on behalf of @max-sixty_ |
||
|
|
ec7db83136 |
docs(config): shorten default-branch override guidance (#3948)
Shortens the default-branch override guidance added in #3947 while keeping the clone-local scope, linked-worktree behavior, and local-branch requirement. > _This was written by Codex on behalf of max-sixty_ |
||
|
|
4e9f706e94 |
fix(switch): track a remote base only when the names match (#3913)
## Problem `wt switch --create <name> --base origin/<branch>` ran `git branch --unset-upstream` afterwards, so the new branch could not push to the base (#713). Nothing said so, and #3912 was the third thread to arrive at the manual `git push --set-upstream origin <branch>` step. Reviewing the docs for it (@max-sixty, [comment](https://github.com/max-sixty/worktrunk/pull/3913#issuecomment-5427192580)) surfaced the better question: should this just copy git? ## Solution Git already ships the rule the unset was approximating — `branch.autoSetupMerge = simple` sets tracking only when the start point is a remote-tracking branch **and** the new branch has the same name as the remote branch. `wt` now defaults to it on the `--create` paths instead of undoing git's `true` after the fact, and documents it in one paragraph of `wt switch`'s `after_long_help`. Three things follow: - **A same-named branch keeps its tracking.** `--create release --base origin/release` was getting unset too, though the tracking it lost was correct. - **An explicit `branch.autoSetupMerge` is honoured.** `wt` picks a different default; it no longer overrides the setting. - **`--create` off a remote base no longer fails outright** for anyone whose config left git no upstream to unset. Under `branch.autoSetupMerge = false` the command exited 128 — `fatal: branch 'feature' has no upstream information` — after creating the branch and the worktree. The DWIM paths (`wt switch release` when only `origin/release` exists) are unaffected: they create `feature` from `origin/feature`, where `simple` and `true` agree. `simple` needs git ≥ 2.37; `MINIMUM_GIT_VERSION` is 2.43. ## Testing `test_switch_create_from_remote_base_no_upstream` becomes `test_switch_create_from_remote_base_upstream`, covering the same #713 property plus the three cases above. Full suite via `cargo run -- hook pre-merge --yes`. <details><summary>Verification (git 2.55.0, debug <code>wt</code>, bare <code>origin</code> with <code>main</code> and <code>release</code>)</summary> Behavior, before and after, in a clone with no local `release`: ```console $ wt switch --create feature --base origin/release --no-cd # default config $ git branch -vv + feature 9e98944 (…/src.feature) r2 # no upstream — unchanged $ git config branch.autoSetupMerge false # before: exit 1 $ wt switch --create feature --base origin/release --no-cd ✓ Created branch feature from origin/release and worktree @ …/src.feature + feature 9e98944 (…/src.feature) r2 $ git config branch.autoSetupMerge always # before: unset anyway $ wt switch --create feature --base origin/release --no-cd + feature 9e98944 (…/src.feature) [origin/release] r2 $ wt switch release --no-cd # DWIM, unchanged + release 9e98944 (…/src.release) [origin/release] r2 ``` The failure this removes, on the pre-change binary: ```console $ git config branch.autoSetupMerge false $ wt switch --create feature --base origin/release --no-cd ✗ git branch --unset-upstream -- feature failed (exit 128) fatal: branch 'feature' has no upstream information $ git worktree list /tmp/asm2/src 257f014 [main] /tmp/asm2/src.feature 653f0dd [feature] # created, then the command failed ``` `branch.autoSetupMerge` semantics, straight from git, no `wt` involved: ```console $ git switch -c t-true origin/release # default `true` * t-true aeb85e2 [origin/release] r2 $ git -c branch.autoSetupMerge=simple switch -c t-simple origin/release * t-simple aeb85e2 r2 $ git -c branch.autoSetupMerge=simple switch -c release origin/release * release aeb85e2 [origin/release] r2 ``` And that `git worktree add -b` — the invocation `wt` actually runs — honours it the same way: ```console $ git -c branch.autoSetupMerge=simple worktree add -b wt-diff /tmp/asm/wt-diff origin/release + wt-diff aeb85e2 (/tmp/asm/wt-diff) r2 $ git -c branch.autoSetupMerge=simple worktree add -b foo /tmp/asm/foo origin/foo + foo aeb85e2 (/tmp/asm/foo) [origin/foo] r2 ``` </details> --- Refs #3912, #713 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
af04e80db0 |
docs(config): document the machine-local default-branch override (#3947)
## Problem `wt config state default-branch set` writes `worktrunk.default-branch` to the repository's *local* git config, so an override lives in `.git/config` — never committed, never pushed, and shared across every linked worktree of the clone. #3946 reports that nothing says so: the detection list names the config key, but the docs never state that setting it is a supported override. That matters for anyone working in a repository they don't own (a client's, an upstream project's) whose remote `HEAD` names a branch other than the one they integrate against — the file-based config the docs point at is exactly what they can't add. There is no default-branch key in `wt.toml` or user config either, so the git config key is the only route. ## Solution Two additions, no behavior change: - `src/cli/config.rs` — a new "Overriding without a config file" section in `wt config state default-branch`'s `after_long_help`, covering where the value is written, that it stays on one machine, that it applies to every linked worktree, that it's the only override there is, and that the branch has to be checked out locally or `set` and every subsequent `wt list` warn about it. The Detection section's existing drift sentence gains the "expected for a deliberate override, and inspection-only either way" qualifier rather than the new section restating it. - `docs/src/content/docs/tips-patterns.md` — a matching tip beside the existing "Reuse `default-branch`" section, which is where the reporter looked. The generated mirrors under `docs/`, `skills/`, and `plugins/` come from the sync test. ## Testing Verified against a scratch clone with the built binary: `wt config state default-branch set integration` writes `[worktrunk] default-branch = integration`, `git config --show-scope` reports `local`, and reading it back from a linked worktree returns `integration`. With `origin/integration` fetched but no local `integration`, `set` warns `▲ Branch integration does not exist locally` and every `wt list` repeats `▲ Configured default branch integration does not exist locally` with the `clear` hint; the summary line gains `1 ahead` only once `integration` is checked out. The console example's output line is the command's real success message. `cargo test --test integration test_docs_are_in_sync` (twice — regenerate, then clean), `cargo insta test --accept --test integration -- test_help`, and `cargo fmt --check` all pass. <details><summary>Scratch-repo verification</summary> ```console $ wt config state default-branch set integration ✓ Set default branch to integration $ git config --show-scope --get worktrunk.default-branch local integration $ git worktree add -q ../wt-int integration && cd ../wt-int && wt config state default-branch integration ``` Stale-override case, in a clone with only `origin/integration`: ```console $ wt config state default-branch set integration ▲ Branch integration does not exist locally ✓ Set default branch to integration $ wt list ▲ Configured default branch integration does not exist locally ↳ To reset, run wt config state default-branch clear ``` </details> --- Closes #3946 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
1133df8804 |
fix(switch): resolve pr:N before pre-switch hooks, and set pr_* for same-repo PRs (#3941)
Fixes #3934. `wt switch pr:<n>` resolved the PR through the forge inside `plan_switch`, which runs *after* `pre-switch` — so the hook got the raw `pr:3933` token as `branch` and `target`, and no `target_worktree_path` even when the PR's branch was already checked out. Separately, `pr_number` / `pr_url` were read back off `CreationMethod::ForkRef`, which a same-repo PR never produces, so the reporter's output shows both unset in *every* hook although the help text promises them. This resolves `pr:`/`mr:` before the hooks (the rule `-`/`@`/`^` already follow, #2310) and gives the PR identity its own field, carried from resolution through the plan into `pre-switch`, `pre-start`, `post-start`, and `post-switch` alike. Verified with two integration tests that fail on `main` (the `pre-switch` hook aborts the switch with `undefined value`) and pass here; full unit + integration suites, clippy `--all-targets --all-features`, rustdoc, and pre-commit are green locally. <details><summary>What each variable does now</summary> For `wt switch pr:3933` where the PR's branch is `feat/issue-3922-list-sort`: | Variable | Before | After | |---|---|---| | `branch` / `target` in `pre-switch` | `pr:3933` | `feat/issue-3922-list-sort` | | `target_worktree_path` in `pre-switch` | unset even when that branch had a worktree | set when it does | | `pr_number` / `pr_url` (same-repo PR, all hooks) | unset | `3933` / the PR URL | | `pr_number` / `pr_url` in `pre-switch` (fork PR) | unset | set | `worktree_path` in `pre-switch` is deliberately unchanged: on a switch that *creates* a worktree there is no destination directory yet, so it stays on the source. The help text claimed it was "the destination" without that caveat — [`src/cli/mod.rs`](https://github.com/max-sixty/worktrunk/blob/4c5e87eff3ebec31cdf6be1ad905bfda0bceeee8/src/cli/mod.rs#L1704) now says which case is which and points at `pre-start` for work that needs the new worktree (which is what the reporter's own `mise-trust` hook already does). </details> <details><summary>Ordering consequences</summary> The forge lookup and its `git fetch` now happen before `pre-switch` runs, so a hook that aborts the switch aborts it after the PR was fetched. That is inherent to the fix — the branch name is the forge's answer. The lookup is still bounded to the argument form that asked for it (`resolve_ref_shortcut_target` returns `None` for anything that isn't `pr:`/`mr:`), and the resolved target is threaded into `plan_switch`, so the forge is queried exactly once as before. The hook-approval gate used to be what surfaced a malformed `.config/wt.toml` before provider selection — `configured_forge_platform` reports an unparsable config as "unset", which would route an intended `forge.platform` override to the wrong CLI. With the resolution now first, that config load is explicit at the top of `resolve_ref_shortcut_target` rather than an accident of ordering (`test_switch_pr_malformed_project_config_bails_before_provider_selection` covers it and caught the regression). `CreationMethod::ForkRef::ref_url` became dead once the identity moved to its own field, so it is removed rather than left with an `#[allow]`. `SwitchResult::Created`'s `pr_number` / `pr_url` are removed for the same reason: the pipeline applies the identity from the resolved argument, which is the only channel that also serves a switch onto an existing worktree. </details> --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
bcdd44d133 |
Count untracked files in list diffs (#3925)
`HEAD±` stopped counting untracked files to avoid creating blobs, which made a moved file look like a large deletion. This uses a temporary intent-to-add index so Git can pair moves before counting line changes, while keeping the real index and object database unchanged. The behavior now applies to default and full list output, the picker, and statusline. Tests cover exact and edited moves, unusual path bytes, sparse checkouts, and generated output. > _This was written by Codex on behalf of @max-sixty_ |
||
|
|
a0cea6072d |
docs(skill): say which worktree a command acts on (#3890)
## Problem `wt` finds the repository from the working directory and the worktree from the command's own arguments, but the `worktrunk` skill never says so — so agents treat the global `-C <path>` as the worktree selector and layer it on top of a branch argument that already names the target. #3889 measured 144 same-repo `wt -C` calls across 87 transcripts, most of them redundant. ## Solution Three changes, matching the fix the issue proposes: - **New "Which worktree a command acts on" section** in `skills/worktrunk/SKILL.md`, placed before the config material so it reads early. Two rules: a command that names a branch already names its worktree, and `-C` moves the working directory (a different repository, a command with no branch argument, or a caller pinned outside a repo) rather than the worktree selection. Two ✓/✗ pairs from the issue make it concrete. - **Skill `description:` extended** so it fires while an agent is composing a command — "working out which worktree a `wt` command will act on, or reaching for the global `-C <path>` to target one" — not only when editing config or debugging hooks. - **`claude-code.md` sentence scoped.** The `-C` endorsement is now explicitly about the marker commands, which "take no worktree argument of their own", with a closing clause pointing at the general case. Verified against the CLI rather than assumed: | Command | Worktree selector | `-C` warranted? | |---|---|---| | `wt switch [BRANCH]`, `wt remove [BRANCHES]...` | branch argument | no | | `wt step diff --branch`, `wt step commit --branch` | `--branch` | no | | `wt config state marker set --branch` | `--branch`, but cwd must be in the repo | yes, when the host pins cwd outside | | `wt merge`, `wt step rebase\|squash\|push` | none — `[TARGET]` is the merge target | yes | Also confirms the issue's second example: `wt switch --create`'s `--base` already defaults to the default branch, so `-C /path/to/repo` "so it bases off main" changes nothing. ## Not included The issue's second suggestion — a paragraph on the `-C` doc comment in `src/cli/mod.rs` — is left out deliberately, as the issue itself flags it as "worth weighing separately": it regenerates the Global Options block on every command page and a large number of `test_help` snapshots. Happy to follow up if you want it. ## Testing `cargo test --test integration test_docs_are_in_sync` regenerated the four mirrors (`skills/worktrunk/reference/claude-code.md`, both plugin-skill copies, and the `.well-known` digest + description) and passes on a second run. `cargo test --test integration readme_sync` (16 tests) and `test_plugin_layout_is_consolidated` are green. The root-relative `/switch/` and `/remove/` links expand to `https://worktrunk.dev/...` in the skill copy as expected. --- Closes #3889 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
7e5d5ded19 |
fix(list): ignore untracked files in conflict probes (#3906)
Fixes #3883. This replaces #3884. Thanks @srobroek for the report, reproduction, and original fix; the commit retains co-author credit. ## Problem `wt list` and `wt list statusline` synthesize trees for their advisory conflict checks. Git writes the blobs, trees, and commits used by those checks into the real object database even though nothing references them. A changing large untracked artifact can therefore add another full copy on every invocation. Redirecting all probe objects solves the growth, but including untracked content still spends time hashing and compressing artifacts that are outside the useful scope of a best-effort conflict estimate. ## Approach - Treat untracked porcelain entries as status-only. They remain visible as `?` changes but do not enter the synthetic conflict tree. - Preserve staged-only tracked changes with `write-tree` against a copied index. When unstaged tracked changes must be included, run pathspec-free `git add -u --sparse` against the copy before Git's existing `merge-tree` simulation. - Fall back to the committed-HEAD conflict probe for clean and untracked-only worktrees, so an untracked artifact cannot suppress a committed conflict. - Redirect every object-producing `list` and `statusline` probe to one invocation-scoped temporary object database. Temporary probe storage prefers the system temp directory and falls back to Git metadata: the common directory for objects and the worktree's Git directory for indexes. If neither location is writable, the command fails instead of writing probe objects into the real database. - Keep the effective real object database and inherited alternates readable through Git's C-style quoted `GIT_ALTERNATE_OBJECT_DIRECTORIES` format. - Hide the exact Worktrunk-owned temporary directory from status and preview tasks when `TMPDIR` is inside a worktree, and unregister it when the final redirected clone drops. The temporary store has no persistent cache, reuse, pruning, or lifecycle policy. The estimate deliberately ignores the case where the target adds a path currently occupied by an untracked file. The real merge still retains Git's overwrite protection and refuses to destroy that file. ## Validation - Current-head focused matrix: 14 passed, covering tracked conflicts, staged deletion of every file, sparse and missing indexes, untracked fallback, both list entry points, invalid `TMPDIR` fallback, read-only stores, relative and absolute inherited object directories, unusual paths, temporary-directory lifetime, and object neutrality. - Documentation sync and formatting pass on the current head; the earlier full pre-merge run also passed help snapshots, doctests, rustdoc, and repository checks. - On the real CLI repro, three changing 1 MiB untracked states grew `main` from 3 loose objects / 12 KiB to 12 objects / 3.05 MiB. This branch remained at 3 objects / 12 KiB. The local pre-merge wrapper still reports the unchanged Rust 1.98 `chunks_exact_to_as_chunks` Clippy lint in `src/git/repository/diff.rs`; the project toolchain contract and CI use Rust 1.97. > _This was written by Codex on behalf of @max-sixty_ --------- Co-authored-by: Sjors Robroek <s.robroek@vxsan.com> |
||
|
|
ff7748036c |
Load repository skills in Codex (#3903)
Expose the existing Claude maintainer skills through Codex's documented repository skill directory. The tracked link keeps one authored skill tree. The layout test covers symlink-preserving checkouts, the docs record the Windows `core.symlinks=false` limitation, and Cargo excludes the compatibility path so the skills are packaged once. Tested with a Codex `$release` discovery probe and targeted layout and package integration tests. CI runs the full lint and platform suite. > _This was written by Codex on behalf of @max-sixty_ |
||
|
|
df2bc6e7f1 |
Require Git 2.43 and test it nightly (#3895)
Worktrunk now uses Git 2.43 as its tested support baseline and checks the version once before dispatching a command. Git 2.43 is Ubuntu 24.04's system package, so CI can exercise the full supported range with an exact nightly row. This is a policy cutoff rather than a feature boundary; Git-dependent commands on older versions fail centrally instead of accumulating compatibility branches. The Git-independent `config shell` namespace remains available so shell startup and generated integration still work while Git is upgraded. The existing nightly full-test matrix gains an exact Git 2.43.0 row. Test fixtures that isolate PATH retain the runner-selected Git instead of falling back to an older platform Git. `wt step relocate` uses `git switch` because Git 2.43 does not support `git checkout --end-of-options`, and the worktree-registration test now lets each Git version generate metadata it can consume. Tested with the full 4,680-test suite on exact Git 2.43.0 and the normal development environment. Also validated the Ubuntu 24.04 amd64 package installation and the workflow with `actionlint`. > _This was written by Codex on behalf of @max-sixty_ |
||
|
|
ad62f2ad81 |
docs(extending): bring every worktree up to date in the wt up recipe (#3882)
The documented `wt up` recipe failed the entire sweep for any worktree with a modified tracked file, even one with nothing to rebase, and skipped the sweep entirely when a single remote failed to fetch. Since aliases are used as hook steps and a non-zero step stops the rest of the pipeline, a sweep that returns non-zero in ordinary use silently cancels whatever the user put after it. Fixing that exposed a second question the issue raised but didn't settle: what a sweep *should* do with a dirty worktree. Skipping is safe but leaves the dirtiest worktrees — the ones you're actually working in, and the primary worktree that project post-merge hooks write into — permanently behind. So this brings them up to date instead. ## The recipe Four changes, each verified against git rather than reasoned about: - **`;` rather than `&&` after the fetch.** `git fetch --all` exits non-zero if any single remote fails, so `&&` let one remote with lapsed credentials skip every worktree's update, including those whose refs fetched fine. The error stays visible either way. - **A dirty worktree fast-forwards instead of failing.** `git rebase` refuses to start when a tracked file is modified or staged, whether or not that worktree has anything to rebase. `git merge --ff-only` is the part of the rebase git will still do there: it advances a branch that is simply behind, and otherwise changes nothing. It never creates or rewrites a commit, and it refuses per file when an incoming change collides with an edit, so the worktree is either advanced or left exactly as it was. - **`git rebase --abort` runs only when a rebase is actually in progress.** The mid-rebase test is named `rebasing` and reused at both call sites, which also drops the duplicated `test -d … -o -d …` and its obsolescent `-o`. A refusal leaves nothing to abort, and the unconditional abort answered it with `fatal: no rebase in progress` and exit 128 in place of git's own message. The refusals all share one property — git declined atomically and left nothing behind — so a rebase that never starts is not a sweep failure, and the sweep exits non-zero only for an abort that itself fails, leaving a worktree that needs attention. - **`--no-autostash` on both arms.** This is the third direction the issue raised, and the measurement settles it: with `rebase.autostash = true` and no flag, an autostash whose pop conflicts leaves `UU` markers in the worktree, a stash entry, **and exits 0** — so the sweep prints `✓ Completed in 3 worktrees` over a worktree it just left mid-conflict. `merge.autostash` breaks the ff arm the same way from the other side: an autostashed tree is momentarily clean, so a fast-forward that should have refused goes through and the collision lands on the pop instead. `git diff --quiet HEAD` replaces the `git update-index --refresh` line as well as guarding the arms. It's exactly the set `git rebase` refuses on, it refreshes the index itself (checked with `diff.autoRefreshIndex=false`, where `git diff-index` false-positives on a bare `touch` and `git diff` doesn't), and it's the idiom the next recipe on the page already uses. ## Testing Built `wt` and ran the recipe — extracted verbatim from the rendered doc — against scratch repos with three worktrees, for every state below. Each row is the real binary, not reasoning. <details><summary>Verification matrix</summary> | Worktree state | Result | |---|---| | clean, behind | rebased, exit 0 | | clean, up to date | no-op, exit 0 | | dirty tracked, behind, **no overlap** | fast-forwarded, edit preserved, exit 0 | | dirty tracked, behind, **overlapping** the incoming change | refused per file, worktree byte-for-byte intact, exit 0 | | dirty tracked, up to date | `Already up to date.`, exit 0 | | staged new file, behind | fast-forwarded, staged entry preserved, exit 0 | | untracked only, behind | rebased, untracked file kept, exit 0 | | clean, untracked file colliding with a file the incoming commits add | declined, untracked file kept, exit 0 | | dirty **and diverged** (local commit + behind) | declined, worktree intact, exit 0 | | clean, real conflict | auto-aborted, HEAD back at the pre-rebase commit, no `rebase-merge` left, exit 0 | | mid-rebase (stopped at conflicts) | skipped, in-progress rebase preserved, exit 0 | | mid-merge (`MERGE_HEAD`, unmerged files) | declined, `MERGE_HEAD` preserved, exit 0 | | detached HEAD | skipped, exit 0 | | one broken remote | fetch error visible, sweep still ran, other worktrees updated, exit 0 | | `rebase.autostash` + `merge.autostash` both set | behaves identically — no stash created, no markers left | | `pre-rebase` hook refuses | `error: The pre-rebase hook refused to rebase.` shown, worktree untouched, exit 0 — no `fatal: no rebase in progress` masking it | The predicate was checked against every state the issue's table names, confirming `git diff --quiet HEAD` matches rebase's refusal set: | Worktree state | `git rebase --no-autostash` | `git diff --quiet HEAD` | |---|---|---| | unstaged tracked changes, behind | exit 1 | exit 1 | | unstaged tracked changes, up to date | exit 1 | exit 1 | | staged changes | exit 1 | exit 1 | | stale stat entry (bare `touch`) | exit 0 | exit 0 | | untracked only | exit 0 | exit 0 | The script also parses under `dash`, `sh`, `zsh`, and `bash`. </details> `cargo test --test integration test_docs_are_in_sync` regenerates the two mirrors (`skills/worktrunk/reference/extending.md`, `plugins/worktrunk/skills/worktrunk/reference/extending.md`) and passes; `cargo run -- hook pre-merge --yes` is green at 4673 tests, along with the Astro `check` and `build`. Supersedes #3861, which took the skip-only route against the pre-Astro docs path and no longer merges. Closes #3860 > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
be2c088a61 |
docs(llm-commits): describe the Codex model the command actually pins (#3867)
The Codex setup section in the LLM-commits docs describes the pinned model as "the fast mini model", but the command it sits under pins `gpt-5.6-luna` — which is not a `-mini` model. The prose was written in #837 when the pin was a mini variant and was left behind when #3430 bumped `gpt-5.4-mini` → `gpt-5.6-luna`. This rewords the sentence to describe what the command actually pins, following #3430's own framing of `gpt-5.6-luna` as "the fast/low-cost variant of the current recommended (5.6) family". `docs/src/content/docs/llm-commits.md` is the primary (non-command doc, per the sync taxonomy in `docs/CLAUDE.md`); the two mirrors under `skills/` and `plugins/` were regenerated by `cargo test --test integration test_docs_are_in_sync`, which now passes. The branch was rebased onto `main` after #3866 relocated the docs tree, so the edit lands at the post-#3866 path rather than the old `docs/content/` one. No regression test: the change is a single prose sentence with no behavior behind it. The sync test already pins the mirrors to the primary, which is the only mechanical invariant here. <details><summary>Nightly sweep context</summary> Found during the rolling survey (bucket 23/28), reviewing `skills/worktrunk/reference/llm-commits.md`. Provenance: ``` $ git log --oneline -1 -S 'Uses the fast mini model' -- docs/content/llm-commits.md |
||
|
|
8e405bced9 |
docs: rebuild the site with Astro and Starlight (#3866)
The docs now build with Astro and Starlight instead of Zola. This removes the Tera 2 migration blocker from #3827 while keeping the published routes, anchors, generated references, metadata, and crawler URLs stable. It replaces the approach closed in #3840. ## What changed - Move the site into Starlight, with a custom Worktrunk homepage and a shared copper, gold, paper, and ink design system. - Keep generated docs as portable Markdown. The Rust sync pipeline no longer needs Zola shortcodes, template escaping, ANSI-to-HTML conversion, or reverse transforms for skill and README output. - Preserve terminal semantics and command-only copying, add responsive handling for short wide tables, retain stable heading IDs, and give repeated command-reference headings useful search labels. - Move docs CI and publishing to Node, Astro, and built-site contract tests. The tests cover public routes, links, assets, metadata, navigation, tables, terminal frames, and compatibility aliases. - Record demo themes from isolated environments, add a mobile core demo, and test the recording contract. The matching mobile assets are published in `max-sixty/worktrunk-assets`. Closes #3827. ## Verification - `cargo run -- hook pre-merge --yes` (4,660 tests) - `npm --prefix docs run check` - `npm --prefix docs test` (9 tests) - `npm --prefix docs run build` (16 pages) - `npm --prefix docs run test:site` (12 tests) - `pytest docs/demos/tests/test_recording.py` (4 tests) > _This was written by Codex on behalf of max-sixty_ |
||
|
|
5a870224d1 |
Add unified diff to switch picker (#3865)
`wt switch` now opens local rows on a unified diff that combines committed, staged, unstaged, and untracked changes. Working-tree and committed changes remain available as subsidiary tabs, and Tab/Shift-Tab skip tabs without content while Alt-1 through Alt-8 keep direct access. The diff execution path is shared with `wt step diff` through `PreparedDiff`. Untracked files use operation-scoped temporary index copies under the system temp directory, leaving the real index unchanged. Sparse checkouts and temp directories inside the worktree are covered. The implementation requires Git 2.34 for `git add --sparse`, documented in the existing FAQ installation section. The landing row and cacheable branch-only rows are prewarmed. Off-screen worktrees are loaded by the selected-row demand worker, avoiding a skeleton-time `git add -N` walk for every worktree. Tested with `cargo run -- hook pre-merge --yes` (4,670 tests passed; formatting, clippy, docs, doctests, lockfile, and snapshots passed). > _This was written by Codex on behalf of max-sixty_ |
||
|
|
a6f26e5c6a |
docs(agents): document the activity-marker contract for agent CLIs without a plugin (#3848)
## Problem #3847 asks for a documented "generic agent" integration: worktrunk ships plugins for Claude Code, Codex, OpenCode, and Gemini, so users of any other agent CLI have no documented way to get the 🤖/💬 activity markers in `wt list`. The mechanism is already agent-agnostic — the plugins just call `wt config state marker` on their host's session events — but the docs only present manual markers as a personal-workflow convenience, so users reverse-engineer the integration from that section. #3571 (pi / oh-my-pi) is the same gap from a different host. ## Solution A new **Agent CLIs without a plugin** subsection under Activity tracking in [`docs/content/claude-code.md`](https://github.com/max-sixty/worktrunk/blob/main/docs/content/claude-code.md), stating the three-call contract (set 🤖 on session start, set 💬 on turn end, clear on session end) plus the three things that actually bite: - the command resolves the branch from its working directory, so the hook must run inside the worktree (`--branch` where the host pins cwd elsewhere); - `marker set` exits non-zero outside a repository, and hosts differ on what a non-zero hook does — guard it; - pair every set with a clear, and expect a stale marker if the process is killed first. Docs-only. The skill and plugin-skill mirrors are regenerated by the sync test. ## Testing `cargo test --test integration test_docs_are_in_sync` passes (it regenerated both mirrors, committed here). Each claim in the section was verified against a scratch repo with a linked worktree rather than taken from the existing prose: <details><summary>Verification</summary> ``` $ wt config state marker set "🤖" # from /tmp/mrepo.feature-x ✓ Set marker for feature-x to 🤖 $ git config --get worktrunk.state.feature-x.marker {"marker":"🤖","set_at":1787044121} ``` - Works from a subdirectory of the worktree (branch still resolves to `feature-x`). - Outside a repository: `✗ git rev-parse --git-common-dir failed (exit 128)`, exit code 1 — the basis for the "guard it" bullet. - `marker clear` with no marker set exits 0 (`○ No marker set for main`), so a session-end hook is safe to run unconditionally. - `wt list` renders the marker in the Status column as documented. </details> ## Scope Deliberately host-agnostic. The reporter's second ask — a native `wt config plugins copilot` target — is a maintainer call and isn't attempted here: GitHub Copilot CLI does expose the needed events (`sessionStart` / `agentStop` / `sessionEnd`, user-level hooks under `~/.copilot/hooks/`, per the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference)), but nothing in CI can drive a Copilot session to verify a generated hook file end to end. A concrete Copilot config is posted on the issue for the reporter to confirm; if it works, adding it here as a worked example is a natural follow-up. #3594 (native `pi` target) is the adjacent in-flight work and doesn't overlap with this. --- Refs #3847 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
2aab2ba1a2 |
docs(readme): restore the star history chart with a sealed token (#3844)
The chart in the README has been rendering "GitHub restricted access to star data" rather than a chart. GitHub [limited stargazer data](https://github.blog/changelog/2026-06-30-upcoming-access-restrictions-to-public-api-endpoints-and-ui-views/) to a repository's admins and collaborators, and a hosted chart service calls that endpoint as itself — it is nobody's collaborator, so it renders a placeholder instead. star-history accepts a `sealed_token`, one of our GitHub tokens encrypted with their public key, which restores the real chart. Verified against the exact URL this adds: HTTP 200, no `x-chart-status: restricted`, and a 64KB SVG whose axis labels are real data (2026 / April / July, 1K through 6K) rather than the placeholder text. The token behind it is fine-grained and scoped to this repository alone, with Contents read and write. Write access is what GitHub accepts as proof of collaborator status — that requirement is the whole reason the chart broke, and it is genuinely required rather than star-history being confused: the same Actions token gets 403 from both REST and GraphQL under `contents: read` and reads the full history under `contents: write`. It expires **16 Aug 2027**, and when it lapses the chart reverts to the placeholder with no other signal — no failing check, no notification. The sealed value is a ciphertext only star-history can decrypt, so publishing it is safe on its own — a copied `sealed_token` replayed against another repository returns the same restricted placeholder, since GitHub gates on the token's own access rather than on the sealing. What the fine-grained scope buys is separate: a bound on star-history, who decrypt the token to call GitHub and so hold a credential that can push here until it expires. <details> <summary>Alternatives considered</summary> Earlier commits on this branch built and then removed two self-hosted approaches, both of which avoided giving star-history a token at all: - **Render our own chart** — a script paging the stargazers API over GraphQL and emitting an SVG, published to an orphan branch and later generated into the docs site. Worked end to end, but cost 237 lines to maintain and produced a chart that isn't the one people recognise. - **Render star-history's chart ourselves** — their MIT renderer driven by our own data, fully offline. Produces the identical chart, but needs Node and ~105 npm packages in CI plus a pinned clone of their app internals. Both are in this branch's history if the sealed-token arrangement ever stops appealing. The deciding argument against them: GitHub's camo proxy already stands between README readers and star-history's servers, and the sealed token is a ciphertext rather than a credential in the clear, so the privacy case for self-hosting was weaker than it first looked — while the maintenance cost was real. </details> > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |