mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
main
1624 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
f85230dc62 |
Tidy shell-integration output: prompt spacing, restart hint, one install hint (#4059)
Five things the shell-config surface got wrong, all found by running `wt
config shell install` and `wt config show` on a machine with five shells
configured.
**The prompt opened with a blank line.** `prompt_yes_no_preview` printed
one before every prompt, but most prompts are the first thing their
command prints — `wt config shell install`, `wt config plugins claude
install`, the commit-generation offer at the top of `wt merge` — so the
blank was the leading blank `/writing-user-outputs` forbids. Two PTY
snapshots covering the install prompt had a `trim_start_matches('\n')`
compensating for it, now removed. The separator belongs to the narration
it separates from, so the four callers that print first emit it
themselves.
**`wt config shell install` told an already-wrapped shell to restart.**
The hint says integration needs activating, and reinstalling from inside
a wrapped shell is exactly when it doesn't: a version bump, or the fish
`conf.d` to `functions` relocation, which writes a new file for a
wrapper the running shell already has. Gated on
`is_shell_integration_active()`.
**`wt config show` printed one remediation command per problem.** An
outdated wrapper, fish integration at the deprecated `conf.d` path, and
a missing fish completions file each carried their own `wt config shell
install <shell>`, so three problems printed three commands where the
bare `wt config shell install` fixes all of them. Each row now counts
toward the section's single trailing hint, and the fish
deprecated-location row names its destination inline the way the install
output's cleanup line does. `To verify wrapper loaded: type wt` stays
separate, since installing doesn't fix it.
**A deprecated fish wrapper wasn't a config location.** Consolidating
onto the bare command assumed it covers every state the section reports,
and it didn't: with a wrapper at `~/.config/fish/conf.d/wt.fish` and no
`~/.config/fish/functions` directory, fish has no config location, so
the scan skipped it and only `wt config shell install fish` — which sets
`allow_create` through its shell filter — migrated the wrapper. A
worktrunk wrapper at the deprecated path now counts as fish being
configured, just at the old path. That collapses `should_configure` into
`allow_create || has_config_location`, makes config show's skipped-shell
branch for legacy fish unreachable, and gets the wrapper into the report
for a user who doesn't have fish on `PATH` — where the skipped list,
gated on `is_installed`, showed nothing for fish at all.
**The zsh `compinit` snippet rendered plain.** It went through
`format_with_gutter` where the same snippet in the install output goes
through `format_bash_with_gutter`, so one was dim and highlighted and
the other wasn't.
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
da3707f008 | fix(commands): name the configured alias instead of "unrecognized subcommand" outside a repository (#3982) | ||
|
|
6ab31967ff |
Ask the harness what it holds, everywhere wt asks at all (#4054)
`wt config show` read Claude Code's `installed_plugins.json` and Gemini CLI's `~/.gemini/extensions/` directory to report the plugin and extension status. Both reads have the failure mode #4048 removed from the marketplace reader sitting beside them: a store the tool relocated is the same missing path as one it never wrote, so an installed plugin reads as absent and the section prints `Plugin not installed` over a plugin that is installed and enabled. Both answers now come from the tool — `claude plugin list --json` and `gemini extensions list -o json` — through one `harness_listing` helper that the two marketplace readers share. That leaves reading another tool's file with a single case, and a rule rather than an exception behind it: wt reads `~/.claude/settings.json` because wt writes it (`install-statusline` merges the `statusLine` key in) and Claude Code has no command that reports the setting back. OpenCode's and Pi's plugin files are wt's own writes too. Everything else it asks. ## The uninstall simplification `claude plugin uninstall` and `claude plugin marketplace remove` both exit non-zero once their target is gone, so `wt config plugins claude uninstall` now runs both through `run_plugin_removal`, each tolerating only the absence Claude Code itself reports. That deletes the coupled early return whose condition had to name both halves (`!plugin_installed && is_marketplace_configured() == Some(false)`), the conditional command preview built to match it, and two tests that had become the same run. The Claude Code and Codex uninstalls are now the same shape. Behavior change: a repeat `claude uninstall` no longer prints `Plugin not installed` and skips. It runs both removals and reports `Plugin & marketplace removed`, which is what `codex uninstall` has always done. ## What was measured Against `claude` 2.1.266 and `codex` 0.153.4, in throwaway `CLAUDE_CONFIG_DIR` / `CODEX_HOME` directories: | command | already in the target state | |---|---| | `claude plugin install` | exit 0, "is already installed" | | `claude plugin marketplace add` | exit 0, "already on disk" | | `claude plugin uninstall` | **exit 1**, "not found in installed plugins" | | `claude plugin marketplace remove` | **exit 1**, "Marketplace 'worktrunk' not found" | | `codex plugin remove` | exit 0 | So the install's short-circuit is a convenience over idempotent commands — an answer wt cannot read costs a redundant run rather than a wrong one — while the uninstall's tolerance is load-bearing. `codex plugin remove` keeps `run_plugin_cli`, since it has nothing to tolerate. End to end, with a shim forwarding to the real `claude` except forcing the removals to exit 1: an uninstall over an installed plugin exits 1 carrying claude's own error, and a repeat uninstall over nothing exits 0. `wt config show` reports `Plugin installed` / `Plugin not installed` correctly across an install and an uninstall of the real plugin. ## Cost `wt config show` gains one spawn per section it renders: ~140 ms for `claude`, ~630 ms for `gemini`, taking a run with both CLIs installed from ~0.6 s to ~1.4 s. Neither query reaches the network. It is a hand-run diagnostic rather than a hot path, so the two calls stay sequential; running them concurrently would need the section renders plumbed into threads. ## Tests `is_plugin_installed` mutated to a confident `Some(false)` fails four tests, including `test_plugins_claude_uninstall_command_fails`, which is the one that would otherwise swallow a genuine failure. The harness needed one change beyond the fixtures. `configure_mock_commands` prepends mock-bin to the *caller's* `PATH`, so a real `claude` or `gemini` on the developer's machine can answer in a mock's place — which is why `setup_mock_claude_installed` and `setup_mock_gemini_installed` now write a mock rather than only setting the detection override. ## Review follow-up `ConfigPluginsClaudeCommand::Uninstall`'s `after_long_help` still described the early return this PR deletes ("skips gracefully only when both are already gone"). It now names the current contract, and the recovery note it carried is still true and stays: > Requires `claude` CLI. Both removals run every time, tolerating only the "already gone" error Claude Code itself reports. Running it again is safe, and finishes an uninstall that removed the plugin and then failed on the marketplace. The Codex counterpart never described a skip, so it is unchanged. `claude install`'s "Skips gracefully if already installed" still holds. > _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_015DzKvVGJbopPJan9YYpz4T --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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>
|
||
|
|
f5cac67b77 |
fix(config): ask the harness whether the marketplace is gone (#4048)
`wt config plugins claude uninstall` and `codex uninstall` could print `Plugin & marketplace removed` and exit 0 over a marketplace that was still configured. `plugin marketplace remove` exits non-zero when the marketplace is not configured, which is the state uninstall is trying to reach, so a second run would otherwise fail with nothing left to do ([#4033](https://github.com/max-sixty/worktrunk/pull/4033)). To tell that from a removal that genuinely failed, `run_plugin_removal` asked the harness's own config file. That reader could not fail loudly: a record the harness renamed or moved is the same missing key or missing file as a record it never wrote, so a confident absence was also the answer for every genuine failure. For Codex this isn't hypothetical. `codex plugin marketplace remove` deletes the whole `marketplaces` key along with the last entry under it, leaving an empty `config.toml`: ```console $ codex plugin marketplace add max-sixty/worktrunk $ cat $CODEX_HOME/config.toml [marketplaces.worktrunk] source_type = "git" source = "https://github.com/max-sixty/worktrunk.git" $ codex plugin marketplace remove worktrunk $ cat $CODEX_HOME/config.toml $ ``` So an absent `marketplaces` key had to read as "already removed" for a second uninstall to succeed, and that same reading swallowed every real failure. Making the reader distinguish "no key at all" from "key present, worktrunk absent" would just trade the false success back for the failure it was introduced to fix. Both harnesses publish their marketplaces as structured output, so this asks them instead: ```console $ claude plugin marketplace list --json [{"name":"worktrunk","source":"github","repo":"max-sixty/worktrunk"}] $ codex plugin marketplace list --json {"marketplaces":[{"name":"worktrunk","root":"…"}]} ``` An empty list is the harness saying it holds nothing, which is a different fact from a file that isn't where wt looked. A harness that won't answer — the subcommand gone, output that isn't JSON, or entries that aren't named marketplace objects — yields `None`, and the removal's error stands. The shape check now fails closed rather than open. The cost is that a repeat uninstall against a harness with no `--json` loses its clean exit and shows the harness's own "not configured" message instead. Reading Codex's config also meant wt carried its own copy of the `CODEX_HOME` resolution rule. The child reads the variable itself, so `codex_config_dir` goes with the reader. <details><summary>Verification and tests</summary> Verified end-to-end against the real `claude` (2.1.265) and `codex` (0.153.4), with a shim that fails only `plugin marketplace remove` and forwards everything else. With the marketplace really configured both uninstalls surface the error and exit 1; with nothing configured both exit 0. The mock-driven tests move from config-file fixtures to the mock's `plugin marketplace list --json` stdout, which is now the whole variable between the cases. Per harness: an empty list and a list naming only another marketplace both tolerate the failed removal; a list naming worktrunk surfaces it; and three unusable answers — the subcommand rejecting `--json`, stdout that isn't JSON, and a list reshaped so it parses and means something else — each leave the error standing. The three unusable cases assert the exit status and stderr directly rather than snapshotting, since they share one outcome that the single-case tests either side already pin. Both guards were checked by mutation: making `plugin_marketplace_list` return an empty list on a non-zero exit fails `case_1_unsupported`, and dropping the entry-shape `?` in `marketplace_listed` fails `case_3_reshaped`. Local gate: `cargo run -- hook pre-merge --yes` — 4832 tests passed, `cargo fmt`, clippy, and `pre-commit run --all-files` clean. </details> > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d41ba2b1e5 |
fix(switch): name the directory the --execute program runs in in its header (#4043)
## Problem The `Executing (--execute) @ …` header rendered `hooks_display_path` — the worktree the *background hooks* run in. The program runs wherever the switch cd'd, which is that worktree only when the user was at the source worktree's root. Two cases break the claim: - `--no-cd` deliberately starts the program in the invoking directory, so `output::execute` gets no `target_dir` at all. `wt switch --create test --no-cd -x pwd` announced `Executing (--execute) @ ~/repo.test` and then printed `~/repo`. - A switch from a subdirectory keeps the user's position: `handle_switch_output` passes `resolve_subdir_in_target(...)` to `change_directory`, so the program starts in `<worktree>/<subdir>` while the header named the worktree root — the same wrong-directory shape one level out. The header is the only signal a user gets about where the program runs, so a wrong one sends them looking in the wrong place. In [#4042](https://github.com/max-sixty/worktrunk/issues/4042) the reporter concluded the `--execute` template variables were broken and enumerated all of them, when the variables were correct and the directory was the thing `--no-cd` had moved. ## Solution `handle_switch_output` now returns both annotations as `SwitchDisplayPaths` — `hooks`, the worktree root the hooks really do run in, and `execute`, derived from the `cd_target` it hands `change_directory`. The header therefore names the directory `output::execute` will use, and cannot drift from it: there is one value, computed where the `cd` is decided rather than reconstructed at the call site. The path is still annotated only when the user's shell won't be there. Under `--no-cd` there is no `cd_target` and the header reads `Executing (--execute):`; with shell integration active the shell follows the program to the same directory, so nothing is annotated then either. ## Testing Two integration tests in `tests/integration_tests/switch.rs`, both failing on `main`: - `test_switch_no_cd_execute_header_omits_worktree_path` — `wt switch feature --no-cd --execute pwd` asserts the header names no path (fails with `Executing (--execute) @ ~/repo.feature`). - `test_switch_execute_header_names_preserved_subdirectory` — switches from `apps/gateway/`, asserts `pwd` prints the target's `apps/gateway` and the header names it (fails with `Executing (--execute) @ ~/repo.feature`). The existing `switch_no_cd_execute_does_not_emit_cd` snapshot pinned the old header — its stdout was already `_REPO_` while its stderr claimed `_REPO_.exec-test` — and is updated. The 336 tests matching `switch`, `execute`, and `directives` pass, as do `cargo test --lib --bins`, `cargo clippy --all-targets`, and `cargo fmt --check`. Closes #4042 — automated triage --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.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>
|
||
|
|
c40e1220ce |
fix(config): reach the marketplace when the plugin is already gone (#4034)
`wt config plugins claude uninstall` cannot finish a half-done uninstall. It returns early on `!is_plugin_installed()`, so once the plugin is gone the command stops there — and the state where the plugin is gone but the marketplace is not is exactly what a first run leaves behind when the plugin uninstall succeeds and the marketplace removal then fails. Re-running prints `Plugin not installed` and exits 0 with the marketplace still configured, and nothing in `wt` will remove it. The early return now requires both halves to be gone, and takes only a confident `Some(false)` for the marketplace, matching what `run_plugin_removal` is willing to trust. The plugin step becomes conditional, and the `?` preview lists it only when it will run, so the preview stays equal to the set of commands the uninstall spawns — the property `test_plugins_claude_prompt_previews_commands` pins. The marketplace removal still runs unconditionally. Gating it on the config reading `Some(false)` was the first thing I tried, and it is wrong: it makes `run_plugin_removal`'s tolerance unreachable in the cases that exercise it, so `test_plugins_claude_uninstall_tolerates_absent_marketplace` and its siblings would pass while never attempting the removal they exist to tolerate. Leaving the removal unconditional keeps that tolerance the thing that decides whether an absent marketplace is a failure. `test_plugins_claude_uninstall_removes_marketplace_left_without_plugin` covers the recovered state: no `installed_plugins.json`, a `known_marketplaces.json` holding worktrunk, and a run that skips the plugin step and removes the marketplace. Every other uninstall snapshot is unchanged, which is what confirms this reaches only the state that was previously unreachable. `wt config plugins claude uninstall --help` promised the opposite of the new behavior — "Skips gracefully if the plugin is not installed, leaving the marketplace in place" is precisely the state the command no longer skips — so it now states the real condition. Nothing caught that: the string is in no generated mirror and no snapshot, so `test_docs_are_in_sync` and the help snapshots both stayed green while the sentence described behavior that was gone. Both uninstall pages are `test_help` cases now, since what each says about skipping depends on what the harness's config still holds. The Codex uninstall has no equivalent stranded state, which is measurable rather than a judgment call: `codex plugin remove` exits 0 on an absent plugin, so its first step never blocks the re-run from reaching the marketplace step. `claude plugin uninstall` exits 1 there, which is what made the `is_plugin_installed` guard necessary on that side and gave it a state to strand. Raised as a non-blocking observation on #4033. It is pre-existing rather than introduced there, but it is in the function that PR was editing. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
74ad58ac6f |
fix(config): let uninstall succeed when the marketplace is already gone (#4033)
`wt config plugins codex uninstall` fails when run twice. `#4019` made both harnesses' uninstall remove the marketplace after the plugin, and `plugin marketplace remove` exits non-zero when the marketplace is not configured — which is the state uninstall is trying to reach. So the second run reports a failure having nothing left to do. The Claude side has the same shape and is only masked by its `is_plugin_installed` early return, which returns before the marketplace step. Measured against the real CLIs, each in a throwaway config dir: | command | target absent | exit | | --- | --- | --- | | `claude plugin marketplace remove worktrunk` | `Marketplace 'worktrunk' not found` | 1 | | `claude plugin uninstall worktrunk@worktrunk` | `not found in installed plugins` | 1 | | `codex plugin marketplace remove worktrunk` | `is not configured or installed` | 1 | | `codex plugin remove worktrunk@worktrunk` | reports removed | 0 | `codex plugin remove` tolerating an absent plugin is what `#4019`'s "Superseded during review" note established; the marketplace removal beside it does not, which is what this fixes. `run_plugin_removal` runs the removal and, when it fails, asks the harness's own config whether the marketplace is still there. The answer is three-way. A config the harness has never written records no marketplaces, so its absence is a confident `Some(false)` and the command succeeds. A marketplace still present is `Some(true)`, a genuine failure, and the harness's stderr surfaces in the gutter exactly as before. A config that exists and will not read or parse is `None`, and the error stands there too: a reader that cannot see the marketplace has not established that the removal worked. That third answer is what keeps the fix from failing open, and it has to rest on the file's shape rather than on parsing alone. A key lookup answers "absent" for any JSON that simply lacks the key, so `known_marketplaces.json` reshaped the way the `installed_plugins.json` beside it wraps its map in a `version` key would parse, miss, and report a confident no — every genuine failure silently reported as success for as long as the shape held. The Claude reader therefore requires every value to be a marketplace object; the Codex reader rules out the one shape available to it, a `marketplaces` that is not a table, and claims nothing more, since a fresh `config.toml` legitimately has no such key. Both ask `try_exists` rather than `exists`, which reports `false` for a directory it cannot stat. The removal runs in every case, so the `?` preview stays an accurate list of the commands the uninstall spawns, which `test_plugins_claude_prompt_previews_commands` pins. `is_marketplace_configured` reads `known_marketplaces.json` for Claude, beside the `installed_plugins.json` that `is_plugin_installed` already reads, and Codex's `[marketplaces.<name>]` table in `config.toml`. <details><summary>Tests, and one existing test whose setup changed</summary> Each harness gets five cases, differing only in what the config holds: no config file, a config holding a different marketplace, a config holding worktrunk, a config that will not parse, and a config reshaped so it parses but no longer means what the reader expects. The middle one is the state a second `uninstall` actually lands in — Codex keeps its model and other settings in `config.toml`, and a Claude user with any other marketplace still has a `known_marketplaces.json` — so it, not the missing-file case, is the branch this fix exists for. Mock helpers fail only the marketplace removal, so the cases differ in config state alone, and the fixtures take the file body. `test_plugins_claude_uninstall_second_step_fails` needed a configured marketplace added to its setup. Without one it had become the already-absent case, so it would have asserted success while its doc comment says the error surfaces. With the fixture its snapshot is unchanged from before this branch. Tests drop an ambient `CODEX_HOME` rather than pinning one, so the Codex config lookup stays inside the temp home the way `CLAUDE_CONFIG_DIR` already does. Removing it keeps the variable out of every snapshot's env block, where pinning it would have churned each one and needed a new redaction. A separate test then points `CODEX_HOME` at a directory holding the only copy of the marketplace record, so the lookup has to follow the variable to find it; it asserts on the exit status rather than a snapshot, for that same reason. The lookups carry no unreachable arms. `codex_config_dir` does not expand a leading `~/`, which was mirrored from `claude_config_dir` — where the comment explains a literal `~` only arrives when the variable is set outside a shell — and nothing sets Codex's that way here. Both readers fold their early returns into the paths the tests drive: resolving the config dir and reading the file are one `and_then`, so the missing-file case covers the `else` arm, and a malformed file falls out of `is_ok_and` rather than a `let ... else` of its own. Local gate: `cargo fmt --check`, `cargo clippy --all-targets --all-features`, `cargo test --lib --bins`, `cargo test --test integration` (2090 passed), and `pre-commit run --all-files`. </details> > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
10cb25901e |
Warn when wt list can't parse the project config (#3993)
A `.config/wt.toml` that doesn't parse left no trace on `wt list`, which reads project config only through accessors that degrade to `None`, so a broken file could sit in a repo indefinitely. `ProjectConfig::load` now returns the typed `LoadError::File` the user-config layer already uses, and `wt list` warns before collection so the message lands above the rows. Commands that run project config still fail hard. `wt hook show` and `resolve_copy_ignored_config` drop a `.context()` that duplicated the header `project_config()` already adds, and `LoadError::File` says `@` before the path like the warning that renders it. One `emit_config_load_warning` serves both config kinds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb |
||
|
|
3e86e5824b |
Report an empty listing in a bare repo instead of nothing (#3992)
`wt list` in a bare repository with no linked worktrees printed nothing and exited 0. Collection returned early on an empty worktree list, before branch rows were built. It now carries on: `--branches` lists the repo's branches, `--format json` returns its envelope with an empty `items`, and a listing with no rows reports `No worktrees` on stderr with a hint, leaving stdout empty for pipes. The main-worktree lookup returns `Option` rather than erroring when nothing can be the main worktree. The hint follows the branch inventory. A `git clone --bare` reaches this listing with its branches intact, where `wt switch --create <branch>` errors, so it points at `wt list --branches` and `wt switch <branch>` instead; a branchless repo keeps `--create`. Dropping the early return exposed the stale-default-branch check to a repo with no branches at all, where `symbolic-ref HEAD` names a branch that `refs/heads/` doesn't carry. The warning is gated on a non-empty branch inventory; the drop that keeps downstream tasks off the missing branch follows the missing value instead, so a detached HEAD whose last branch was deleted no longer ends in three `Needed a single revision` failures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb |
||
|
|
a9bf06a0bc |
test: refresh snapshot metadata headers (#3987)
Header-only refresh of every insta snapshot, no behavior change. The `info.env` block the test harness records (`GIT_CONFIG_*`, `WORKTRUNK_TEST_PROBE_TIMEOUT_MS`, …) had only reached snapshots rewritten since those keys were added, so any branch that re-accepts an older snapshot drags the header refresh into its diff. With the ~2,800 lines of that churn landed here, follow-up PRs show only content changes. How it was produced: `cargo insta test --force-update-snapshots --all-features` on a fresh `main` worktree (4715 tests green, PTY tests included), then a script compared each changed file's body (the text after the frontmatter) against HEAD and reverted any that differed — none did, so the CI-generated picker snapshot that renders differently on macOS is untouched. Force-update also rewrote inline snapshots in nine source files, only their delimiter style; those were reverted so the diff is `.snap` files only. > _This was written by Claude Code on behalf of max-sixty_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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_ |
||
|
|
5895c41fe1 |
Soften explicit command approval prompts (#3953)
Use a neutral heading when `wt config approvals add` reviews or records commands. Approval prompts that interrupt command execution keep the warning. > _This was written by Codex on behalf of max-sixty_ |
||
|
|
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> |
||
|
|
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_ |
||
|
|
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> |
||
|
|
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_ |
||
|
|
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_ |
||
|
|
246c6bd919 |
fix(list): keep [list] columns out of the --format json plan (#3812)
Closes the `[list] columns` half of #3787, per the call in [this
comment](https://github.com/max-sixty/worktrunk/issues/3787#issuecomment-5273942067):
JSON always emits the same shape, and `list.columns` only affects the
actual columns.
Before, `--format json` planned `all_columns` (source `Default`)
*unioned* with the selection's forced-on columns, so the selection
reached JSON in one direction only — it couldn't narrow the emitted
fields, but a listed `ci` did force the forge fetch on without `--full`.
That made a presentation setting decide whether a machine-readable call
talks to GitHub, which is the thing the Neovim plugin in #3787 had to
pin `--config-set 'list.columns=[…]'` against. Now the JSON branch plans
`all_columns` alone; `--full` is the only switch for the gated data, and
it's the one a caller controls.
The table and the `wt switch` picker are untouched — a listed `ci` still
renders the CI column without `--full`, and the picker still unions the
selection in so its table matches `wt list`'s.
Only `ci` and `summary` are affected: every other column is ungated, so
`full_plan()` already covered them, and custom columns require no
background task.
**For the release note — this changes schema 1 too.** A caller with
`[list] columns = […, "ci"]` and no `--full` used to get the `ci` object
in schema-1 JSON and now won't; schema 1 has no `collected` envelope to
say why. The schema-1 `ci` row already documented `` `--full` only ``,
so the docs get *more* accurate, but the observable output changes for
anyone who was relying on the forcing path. Schema 2 reports the same
narrowing through `collected.ci`.
Docs updated in `after_long_help` (the `[list] columns` section plus the
schema-2 `pr`, `summary`, and `checks` rows — `summary` now names
`--full` alongside `[list] summary = true`, and `checks` names the
`--full` gate it shares with `pr`), with the generated mirrors,
`dev/config.example.toml`, and the `--help` snapshots regenerated. The
`CLAUDE.md` network inventory and the `collect` planning comment now
record the exemption too.
<details><summary>Test</summary>
`test_list_json_columns_selection_does_not_force_ci` in
`tests/integration_tests/list_config.rs` asserts schema 2's
`collected.ci` across three configs: unset (false), `columns =
["branch", "ci"]` without `--full` (false — the regression this fixes),
and the same with `--full` (true). `collected` records what the plan
requested rather than what a fetch returned, so the test needs no forge
and no `gh` on PATH. It sits next to
`test_list_json_ignores_columns_selection`, which owns the narrowing
direction, and `test_list_config_listed_column_overrides_full_gate`,
which owns the table's forcing behaviour and still passes unchanged.
Ran locally: full `cargo test --test integration` and `cargo test --lib
--bins`, plus `cargo clippy --all-targets` and `cargo fmt --check`. One
unrelated failure,
`test_copy_ignored_preserves_file_executable_permissions`, is a umask
artifact of this sandbox (expects `0644`, the runner's `umask 002`
produces `0664`); it touches no code in this diff.
The docs-row follow-up in
|
||
|
|
92dfb686bb |
feat(approvals): let wt config approvals add --yes record approvals without a TTY (#3819)
`wt config approvals add` refused every non-interactive run — even with `--yes`, whose hint then suggested the flag already passed — so there was no way to pre-approve a project's commands unattended. An orchestrator (tend's Codex Cloud container was the motivating case) had to hand-write `approvals.toml` from `wt config approvals list --format=json` output, a third-party reimplementation of `add` that breaks whenever the schema changes. The `wt config approvals` docs already promised "`--yes` to bypass prompts in CI" and described `stale` entries as "what `--yes` would silently re-approve"; behavior now matches them. The two `--yes` meanings stay distinct: on a command that runs project commands it grants consent for that run alone and records nothing (unchanged), while on `add` — whose product is the record — it lists what it trusts and writes it. `add` no longer routes through `approve_command_batch` (the execution gate) for this: it prompts or announces, then saves itself, which also makes a failed `approvals.toml` write fail the command instead of warning behind a `✓ saved` line and exit 0 — an orchestrator reading only the exit code would otherwise walk into the prompt it just paid to avoid. The non-interactive hint's pre-approval suggestion now carries `--yes` (`run wt config approvals add --yes`), since a hint reached in CI must name a command that runs there. Per the existing `list --format=json` docs, `add --yes` re-approves templates edited since an earlier approval without comment; the `add` help now says so and points at the `stale` field for reading them first, and the worktrunk skill's escalation rule tells agents not to reach for it on a user's behalf. > _This was written by Claude Code on behalf of max-sixty_ |
||
|
|
aa9d8c43df |
feat: add remote_repo variable (#3745)
Add a `remote_repo` variable that returns the repo name from the remote URL. Unlike `repo`, it stays consistent even if the clone was renamed. Feel free to reject, or suggest other names for the variable. But this change would improve my workflow. I hope you don't mind my submitting a PR before opening an issue. Thanks for an amazing developer tool! AI Disclosure 🤖: I used Claude Code to generate the changes, but reviewed every line and made adjustments. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
96c6c846f7 |
fix(shell): register completions under the --cmd name, not clap's (#3817)
## Problem
`wt config shell init <shell> --cmd <name>` renames the shell wrapper
and its lazy completion loader, but the registration that loader evals
comes from clap, which derives every identifier in it from its own
compile-time `Command` name (`wt`) — not from `argv[0]` and not from
`--cmd`. The two halves never agreed:
```console
$ wt config shell init zsh --cmd wot | grep _clap
if ! (( $+functions[_clap_dynamic_completer_wot] )); then
_clap_dynamic_completer_wot "$@"
$ COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*' | sort -u
_clap_dynamic_completer_wt
```
Nothing completed, and because the guard never became true the
completion script was regenerated and re-evaluated on *every* TAB. Same
shape in bash (`_clap_complete_*`); PowerShell emitted
`Register-ArgumentCompleter -Native -CommandName wt`, so the `--cmd`
name was never registered at all. The documented `--cmd=git-wt` case
(the Windows Terminal conflict) was broken too — including for a binary
genuinely installed under that name, since clap's name comes from the
declaration rather than `argv[0]`.
There is a second, sharper edge: zsh's registration ends with `compdef
<completer> <cmd>`, so the first TAB on `wot` also bound worktrunk's
completer to plain `wt` — handing completions to the *other* `wt` that
`--cmd` exists to step around.
fish and nushell were unaffected. Both register a completer that shells
out to the binary rather than depending on a clap-emitted identifier, so
the reporter's "unverified" row for fish is a pass.
## Solution
The bash, zsh, and PowerShell loaders now pass the name they bind in
`WORKTRUNK_COMPLETE_NAME`, and `registration_name()` in
`src/completion.rs` emits the registration under that name (validated
through the same `validate_shell_command_name` guard `--cmd` uses, since
the value lands verbatim in generated shell code). The fallback is
`binary_name()`, which covers a binary installed as `git-wt` and invoked
directly. The templates apply clap's own `-` → `_` escaping to the
function they call, so `--cmd git-wt` guards on `_clap_complete_git_wt`
rather than the invalid `_clap_complete_git-wt`.
That fixes all four shells and the stray `compdef` in one place, rather
than pinning the templates to clap's internal naming:
```console
$ WORKTRUNK_COMPLETE_NAME=wot COMPLETE=zsh wt | grep -oE '_clap_dynamic_completer_[a-z_]*|compdef .*' | sort -u
_clap_dynamic_completer_wot
compdef _clap_dynamic_completer_wot wot
```
## Testing
Two reproduction tests in `tests/integration_tests/completion.rs`, both
failing before the change:
- `test_init_custom_cmd_defines_clap_completer_in_bash` drives the whole
chain through a real bash — generate the init script with `--cmd`, call
the loader it defines, then assert clap's completer function exists
afterwards. Printed `MISSING` before, `DEFINED` after. Cases for `wot`
and `git-wt`.
- `test_completion_registration_uses_shell_integration_cmd_name` covers
zsh and PowerShell, which CI can't drive: the identifier the init script
references must be the one the registration defines, and the `compdef` /
`-CommandName` target must be the `--cmd` name.
`cargo test --lib --bins` and `cargo test --test integration` are
otherwise green (one unrelated failure locally,
`test_copy_ignored_preserves_file_executable_permissions`, from this
sandbox's `umask 0002`), and `cargo clippy --all-targets --all-features`
/ `cargo fmt --check` are clean.
---
Closes #3816 — automated triage
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
|
||
|
|
bdce107d91 |
fix(config): rank env vars and --config-set above project entries (#3790)
Fixes #3788. Layer and specificity were separate steps. `load_with_warnings` flattened system config → user config → `WORKTRUNK_*` env vars → `--config-set` into one document, and the accessors then resolved specificity on that document, so a `[projects."<id>"]` entry answered for the global key of the same name whichever layer set it. `WORKTRUNK_WORKTREE_PATH` could therefore not override a project's `worktree-path`, and a global `--config-set` hit the same wall. Per @max-sixty in the issue thread — "env vars should indeed take precedence over the user project config, we should fix this throughout" — the two invocation layers now cross the axes: they're typed for one run, so they outrank a project entry as well as the global key. Load applies them at both scopes (`apply_invocation_layer_over_projects`, the last step before `finalize`): whatever the layer set is dropped from every project entry, leaving the global key it also set to answer for it. Two kinds of key are held back: - **Keys the layer restates under `projects."<name>"`** — `--config-set 'projects."github.com/owner/repo".worktree-path = …'` is both the highest layer *and* the most specific key, so it still wins over the same layer's global key. - **Composing keys** — hooks, aliases, and `step.copy-ignored.exclude` — whose project-scoped values append to the global ones rather than replacing them. Both already apply, so an env-set hook was never outranked, and dropping the project's copy would silently stop it running. Hook names come from `HooksConfig`'s schema, so a new hook can't be forgotten. Two sections have to go as a unit rather than leaf by leaf. `[commit.generation]`'s mutually exclusive pairs: `template` and `template-file` clear one another in `merge_with` *and* are rejected together by `validate`, so overriding either has to displace both at project scope — otherwise the project's partner would still win the merge. `exclusive_sibling` names those pairs. And `[list.custom-columns]`, which `ListConfig::merge_with` extends per whole column, so a partial removal leaves the project's column replacing the global one anyway — and `ListColumnConfig::template` is required, so it can also strand a column that no longer deserializes. `is_atomic_section` names that table. Both are enumerations, so the pass degrades as a unit behind them: the removals land on a candidate, kept only if it still deserializes and validates. That is the guarantee the env and `--config-set` layers already have, and without it the next required field would answer a stranded leaf with `UserConfig::default()` — costing the user their whole config for that invocation rather than one project entry's precedence. The precedence table now reads: | Source of `worktree-path` | Loses to | |---|---| | `--config-set 'worktree-path = …'` | — | | `WORKTRUNK_WORKTREE_PATH` | `--config-set` | | `[projects."github.com/owner/repo"]` in a config file | either invocation layer | | global `worktree-path` in a config file | all of the above | ## Docs The help text had no precedence section at all — the gap that made this read as a bug — so this adds one under **Environment variables**, plus a pointer from **User project-specific settings**. That supersedes #3789, which documented the old behavior; I'll close it in favour of this. ## Testing Nine unit tests in `src/config/user/tests.rs` cover the table-level rule (both layers, pattern entries, restated project-scoped overrides, untouched sibling keys, composing keys, the exclusive pair, the atomic custom column, a rolled-back layer, and the no-override no-op), and `test_switch_create_invocation_layers_outrank_project_worktree_path` proves it end-to-end — a real process is the only thing that reads `WORKTRUNK_WORKTREE_PATH` off the environment. That test keeps a control showing the project entry still beats the config file's own global key, so it can't pass by project entries having stopped applying. The reproduction from the issue now lands where it says it should: ```console $ WORKTRUNK_CONFIG_PATH="$tmp/wt.toml" WORKTRUNK_WORKTREE_PATH="$tmp/from-environment" \ wt switch --create feature --no-cd --no-hooks --yes --format=json {"action":"created","branch":"feature","path":"/tmp/tmp.jQiTAuNPFd/from-environment",…} ``` <details><summary>Local suite</summary> `cargo test --lib --bins` and `cargo test --test integration` are green apart from `test_copy_ignored_preserves_file_executable_permissions`, which fails in this sandbox because its umask is `0002` (file created `0664`, test expects `0644`) — unrelated to this change and not reproducible on a `0022` runner. `cargo fmt --check` and `cargo clippy --all-targets --all-features` are clean. </details> --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> |
||
|
|
7aba380f0c |
fix(remove): gate removal on the registration, not the repository (#3808)
`wt remove --force` deleted a live worktree of this same repository, uncommitted work included, whenever that worktree had been moved onto another worktree's registered path. The guard added in #3785 asks which *repository* the occupant answers to: a linked worktree's git dir sits under `<common>/worktrees/`, the main worktree's *is* the common dir, anything else is someone else's. A sibling worktree moved onto the path satisfies that — its git dir sits under `<common>/worktrees/` like any worktree of this repo — so it passed, and the fast path renamed the directory into trash and handed the `rm -rf` to a detached process. It is not prunable either: its gitdir file points at a location that exists, so the `is_prunable` arm from the same PR doesn't catch it. Git's own validation is one level finer. `validate_worktree` requires the directory to point back at *this registration*, and refuses this removal with `--force`: ```console $ git -C repo worktree remove --force ../repo.feature fatal: validation failed, cannot remove working tree: '.../repo.feature' does not point back to '.git/worktrees/repo.feature' ``` <details> <summary>Reproducer, verified against a build of main</summary> The occupant has to be *moved* onto the path rather than created there — `git worktree add` refuses a registered path, which is what leaves a plain `mv` as the way this state arises. ```console $ git -C repo worktree add ../repo.feature -b feature $ git -C repo worktree add ../repo.bar -b bar $ rm -rf ../repo.feature && mv ../repo.bar ../repo.feature $ echo PRECIOUS > ../repo.feature/precious.txt $ wt remove --force --yes feature ◎ Removing feature worktree (--force) & branch in background (same commit as main, _) $ ls ../repo.feature ls: ../repo.feature: No such file or directory ``` </details> ## The fix The gate is now git's comparison at git's granularity: the directory's `.git` must name *this* registration, and that registration's `gitdir` file must name the directory back. Repository-level ownership stays as the weaker half of the conjunction — it is what rejects a `.git` file pointing at another repository — and the main worktree is the same test where there is no registration to point back at. `ensure_belongs_to_repo` becomes `ensure_holds_this_worktree`, since it no longer merely asks about repository membership. Resolution moves to `Repository::git_dir_at`, the fs-only resolver the `wt list` prewarm already used (`derive_worktree_git_dir`), generalized to answer for a directory rather than for a known worktree of this repo: its main-worktree branch returned `git_common_dir()` on trust, and now canonicalizes the `.git` it actually found. It also never walks up to a parent, which is what git reads too — `git rev-parse --git-dir` in an emptied worktree can resolve the *enclosing* repository. That settles a second thing the old docstring got wrong. It claimed the plan→rename window was "narrower than `ensure_clean`'s"; in fact `ensure_clean` re-runs `git status` while this gate answered from `GIT_DIRS`, memoized process-wide, so the second call was vacuous and the window — which contains the approval prompt and the `pre-remove` hook — was unguarded. `git_dir_at` reads the filesystem on every call, so the check at the rename now re-decides. The refusal was `Directory @ … is not this repository's worktree`, which is false in the sibling case: it *is* one of this repository's worktrees, just not the one registered there. Its hint didn't fit either — "move the directory aside, then run `git worktree prune`" is a repo-wide prune, and with a sibling moved aside *both* registrations are prunable, so following it clears both and leaves a live checkout that has stopped being a worktree: ```console $ mv ../repo.feature ../repo.aside && git worktree prune -v Removing worktrees/repo.feature: gitdir file points to non-existent location Removing worktrees/repo.other: gitdir file points to non-existent location $ git -C ../repo.aside status fatal: not a git repository: (null) ``` So the error carries where the occupant's own registration records it, and each case gets the remedy that fits. Moving it back to that path leaves prune with only the stale entry to clear: ```console $ wt remove --force --yes feature ✗ Directory @ ../repo.feature does not hold the worktree registered there ↳ Removing it could destroy the worktree registered @ ../repo.other; move the directory back there, then run git worktree prune ``` That path is read through `canonicalize_with_parents`, because a relative `gitdir` entry resolves against `<common>/worktrees/<id>` and would otherwise reach the hint with the `..` chain still in it — and plain canonicalization can't normalize a directory that no longer exists, which is the case the arm is reached for. Normalizing there also makes `crate::path::paths_match`, the crate's canonicalizing comparison over that same helper, the right test for the gate, so there is no second comparison beside it. The gate's fail-closed behavior now rests on that helper resolving `..` through the filesystem rather than collapsing it lexically — across a symlink the two readings name different directories — so `src/path.rs` records the constraint where a lexical rewrite would otherwise read as a tidy-up. The FAQ's "What can Worktrunk delete?" paragraph carried the same "a *different* repository" framing and is corrected. ## Scope Pre-existing, and 0.73.0 already narrowed it — 0.72.0 had no ownership check at all and deleted foreign clones too. The guard has two call sites (`prepare_worktree_removal` at planning, `stage_worktree_removal` at the rename), so this reaches `wt merge --remove`, `wt step prune`, and picker removal, not only `wt remove`. One incidental tightening: `wt remove <bare-repo-path>` previously passed the guard (a bare root's git dir *is* the common dir) and was stopped only by the dirty check, which `--force` skips. It now refuses at the guard. <details> <summary>One residual, left alone</summary> `git worktree repair <path>` after the `mv` produces a *double registration*: both `worktrees/repo.bar/gitdir` and `worktrees/repo.feature/gitdir` come to record the same path, and `git worktree list` reports two worktrees there. In that state the new gate accepts the removal — the occupant does point at the `feature` registration, and that registration does point back — while git refuses, because its path→worktree lookup happens to match the `bar` entry first. Closing it means knowing the registration id at the gate, or scanning every `worktrees/*/gitdir` for duplicate claims. Unchanged by this PR, and reachable only via `mv` followed by `repair`. </details> ## Testing Five new tests, each confirmed to fail with the line it covers reverted and to leave the others passing. Three drive the binary: - **the sibling case** — follows #3785's data-safety model: asserts the filesystem afterwards, not just the exit code, since removal stages by rename and deletes in a detached process. Snapshots the refusal, so the hint and the path it names are pinned. Fails with the pointer-back conjunct removed, while the foreign-repo test still passes without it — the two cover different halves. - **the re-check at the rename** — a `pre-remove` hook repoints the worktree's `.git` at a sibling's registration after planning has already cleared it, which is what makes the second gate's freshness observable. Fails when resolution routes back through the `GIT_DIRS`-cached `git_dir()`. - **a relative `gitdir` entry** — removal succeeds, and git reads the rewritten entry back, which is what makes it the form git itself writes. Rewriting the entry rather than setting `worktree.useRelativePaths` keeps the test independent of the git version that introduced the option. Two sit at the gate, where the CLI can't reach: - **both worktree shapes are accepted** — including the main worktree, whose git dir *is* the common dir. `wt remove` rejects the main worktree well upstream of this gate and a bare repository's worktrees are all linked, so nothing through the CLI would notice that arm inverting. - **the refusal names a normalized path** — asserted against `Diagnostic::render`, since the path is in the hint and `Display` carries only the title. Local gate green: 4607 tests, lints, doctests, rustdoc under `-Dwarnings`. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
667c6efaf0 |
docs(switch): note that Alt-x never forces (#3811)
Requested by @max-sixty in [#3809](https://github.com/max-sixty/worktrunk/issues/3809#issuecomment-5273484502) — a couple of words clarifying that `Alt-x`'s removal is safe-only. The keybinding table read `Remove selected worktree/branch`, which doesn't say the removal never forces; that's what sent the reporter looking for a force-remove that isn't there. The picker hardcodes the safe path — [`prepare_removal`](https://github.com/max-sixty/worktrunk/blob/7a2a3e003e7eed138ff5f2dcd2296f6bbd8e86d4/src/commands/picker/mod.rs#L323-L330) passes `BranchDeletionMode::SafeDelete` and `force_worktree: false`. Deliberately scoped to the table cell, per the "(only)" in the request. The bigger question — whether `Alt-x` should ever pass `-D` — is still open on the issue and isn't touched here. Primary source is `after_long_help` in `src/cli/mod.rs`; the three mirrors and the `--help` snapshot are regenerated. Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> |
||
|
|
1636b78ddf | fix(gitlab): forward glab's verdict when the project lookup fails (#3799) |