Commit Graph

1003 Commits

Author SHA1 Message Date
Worktrunk Bot 7f36ece541 feat(config): show project identifier in wt config show (#2827)
## Summary

Closes #2826. Surfaces the project identifier (`<host>/<owner>/<repo>`
from the primary remote, or the canonical repo path as a fallback) in
the PROJECT CONFIG section of `wt config show`, so users can find the
right key for `[projects."..."]` in their user config without
hand-deriving it. Also adds an `identifier` field to `wt config show
--format=json`.

Docs expand the *User project-specific settings* section with the new
how-to-find-it note plus single/concurrent/pipeline hook examples — the
reporter's TOML showed both pieces were unclear, even though the
array-of-tables form they tried was correct.

## Drive-by

`test_config_show_github_remote` and `test_config_show_gitlab_remote`
used `git remote add origin …` against the standard fixture, which
already has an `origin`. `git remote add` failed silently (output isn't
checked) and the test snapshotted the fixture's original `../origin.git`
URL instead of the platform URL it was meant to exercise. The new
Identifier line in the regenerated snapshot made this visible; both
tests now use `set-url`, matching the existing
`test_config_show_full_gitea_remote`.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (passes; nushell-only
`shell_wrapper::*::case_4` failures are CI-environment, not regressions)
- [x] `cargo test --test integration` — 1738/1738 passing
- [x] `cargo test --lib --bins` — passing
- [x] `cargo test --test integration test_docs_are_in_sync`
- [x] Verified manually: `wt config show` in a repo with a
`github.com:max-sixty/worktrunk.git` remote prints `Identifier:
github.com/max-sixty/worktrunk`

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:21:45 +00:00
Maximilian Roos 5b2a41ee73 feat(plugin): detect Gemini extension; document OpenCode and Gemini install (#2819)
Closing two gaps a prior plugin-implementation review surfaced: Gemini's
native install path was invisible (no `wt config show` section, no
user-facing docs), and OpenCode — which has the most substantial
implementation of the four — was absent from the plugins page despite
being the only tool that ships activity tracking via a worktrunk-written
plugin.

`wt config show` now renders a `GEMINI CLI` section gated on
`which::which("gemini")`. Installed state is detected by parsing
`~/.gemini/extensions/worktrunk/gemini-extension.json` and checking
`name == "worktrunk"` — exactly where `gemini extensions install` clones
the extension. Fail-closed on a missing home, unreadable file, or
invalid JSON, matching the existing claude/codex/opencode detection.
Test infrastructure (`gemini_installed` field on `TestRepo`,
`setup_mock_gemini_installed`, `setup_gemini_extension_installed`) and
the two new snapshot tests mirror the OpenCode equivalents
line-for-line.

`docs/content/claude-code.md` retitled "Agent Integration" and
restructured around a four-tool capability table covering configuration
skill, activity tracking, worktree isolation, and `/wt-switch-create`.
OpenCode and Gemini get install subsections; the activity-tracking
section is generalized from Claude-only to Claude+OpenCode+Gemini so the
heading matches the table. A Codex-only sentence under "Worktree
isolation" was redundant with the table and dropped.

## Verified end-to-end

After **v0.52.0** went `Latest`, `gemini extensions install
https://github.com/max-sixty/worktrunk` resolves via `/releases/latest`
→ the v0.52.0 source tarball (which now carries the root
`gemini-extension.json` from #2807) → install succeeds. `gemini
extensions list` shows `Type: github-release`, `Release tag: v0.52.0`,
with both skills (`wt-switch-create`, `worktrunk`) loaded via
`${extensionPath}/skills/`. Live `wt config show` reports `✓ Extension
installed`. The bare URL is what the docs use — `owner/repo` shorthand
returns "Install source not found" against gemini-cli, so the full URL
is the right form.

Per the `release` and detection paths in gemini-cli 0.42
(`bundle/chunk-CHERUG6W.js` lines 44170–44290 / 44640–44675), the URL
install always tries `releases/latest` first when releases exist, and
only auto-falls-back to `git clone` if there's no release data at all —
so the work landed in #2807 on `main` only became user-visible once
v0.52.0 was cut. This PR ships the detection and docs that have now been
confirmed accurate against the live release path.

Pre-merge gate green locally (3747 tests, 0 skipped; pre-commit clean;
`test_docs_are_in_sync` green so the skill reference and `llms.txt` are
in sync).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:33:05 -07:00
Maximilian Roos ace32e0e2c fix: reap orphaned fsmonitor daemons in wt remove internal sweep (#2814)
## Why

Companion to #2813. That PR stops the fsmonitor-daemon leak at its
source (`wt remove` force-terminating a wedged daemon). This PR is
defense-in-depth for daemons orphaned by paths that bypass `wt remove`
entirely — plain `git worktree remove`, a manual `rm -rf`, or a crashed
`wt` — which #2813 cannot cover. Background: a wedged `git
fsmonitor--daemon` makes `git status` (and `wt list`) hang; orphans had
accumulated ~80-deep on one machine.

## What

Adds an orphan sweep (`src/git/fsmonitor.rs`: `classify_orphans`,
`reap_orphan_fsmonitor_daemons`) hooked into the **existing** `wt
remove` internal sweep op — no new always-on/timer mechanism, and
deliberately not wired to `wt list` or any read-only command (killing
processes as a side effect of a read-only command is out of bounds). It
reaps only daemons whose IPC socket resolves to a worktree that no
longer exists, SIGTERM → bounded wait → SIGKILL, socket-scoped via `lsof
-a -p <pid>` (never broad process-name matching). A daemon serving a
*live* worktree is never reaped — proven by unit tests, and the
invariant holds even when the live-worktree set is unknowable:
`live_git_dirs` returns `Option`, and an unknowable set disables
resolved-socket reaping entirely rather than falling back to "reap
everything".

Shares low-level fsmonitor mechanics with #2813 and both touch `faq.md`
/ `troubleshooting.md` / `CHANGELOG.md`; whichever of the two lands
second needs a small dedup and doc reconciliation.

## Testing

`wt hook pre-merge --yes` green (3757/3757). 11 deterministic unit tests
cover the pure logic, including `live_worktree_daemon_is_never_reaped`
and `unknowable_live_set_spares_resolved_socket_daemons`.

`src/git/fsmonitor.rs` is a new file, so it is entirely in patch scope:
94.5% line / 95.4% region. The uncovered lines are real-OS-signal and
live daemon-enumeration paths only reachable by spawning real `git
fsmonitor--daemon` processes; their pure logic is exhaustively
unit-tested. A real-daemon end-to-end test was prototyped and dropped as
flaky and net-negative (daemon-spawn races; risk of SIGKILLing unrelated
dev-machine daemons). These uncovered OS-only lines are an accepted
trade-off — if `codecov/patch` flags them it is a known false positive,
not a gap to fill with a fragile test.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 23:48:58 +00:00
Maximilian Roos 045750f890 fix(remove): force-kill a wedged fsmonitor daemon on worktree removal (#2813)
## Why

`wt remove` already calls `git fsmonitor--daemon stop` before staging a
worktree to trash, but that stop is an IPC request to the daemon itself.
When the daemon is *wedged* — the common failure that makes `git status`
(and therefore `wt list`) hang — it can't answer the IPC, so the stop is
a silent no-op and the daemon leaks. In practice this accumulated ~80
orphaned `git fsmonitor--daemon` processes on one machine, one of which
wedged and hung `wt list` outright.

## What

Adds a canonical `stop_fsmonitor_daemon` helper: after the IPC stop, it
resolves the daemon's PID from its IPC socket and escalates SIGTERM →
bounded wait → SIGKILL, scoped strictly to the removed worktree's own
socket (never matched by process name, never another worktree's daemon).
All three removal paths (library, foreground, detached-background) route
through the one helper; the redundant `git ... fsmonitor--daemon stop`
shell fragment in the detached path is deleted so there is a single
canonical stop. Force-kill is Unix-only with a clean `#[cfg(not(unix))]`
no-op (Windows keeps the IPC stop, where the daemon uses a named pipe).

This is the source-level fix for the leak on the removal path. Companion
PR #2814 adds a defense-in-depth sweep for daemons orphaned by paths
that bypass `wt remove` (plain `git worktree remove`, manual `rm`, a
crashed `wt`). The two branches share low-level fsmonitor mechanics and
both touch `faq.md` / `troubleshooting.md` / `CHANGELOG.md`, so
whichever lands second will need a small dedup and doc reconciliation.

## Testing

Unit tests cover the deterministic logic: git-dir/socket resolution for
a linked worktree, the SIGTERM-honored fast path, and SIGTERM-ignored →
SIGKILL escalation (with a readiness handshake to avoid a
signal-vs-trap-install race). `wt hook pre-merge --yes` is green. The
`lsof`-failure and git-dir-error arms are single best-effort
`log::debug!`+return lines that fall through to the existing fail-open
behavior.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 16:30:19 -07:00
Maximilian Roos 1cc89d172e docs(claude): slim root CLAUDE.md, relocate detail to sibling files (#2816)
The root `CLAUDE.md` loads into every conversation, so its ~510 lines
were paid on every session. This cuts it to ~190 lines without losing
any documented behavior or invariant.

Three things happened: derivable/stale content was removed (the JSON
Output Format pointer, just a redirect to `wt list --help`), prose was
tightened throughout, and task-specific detail was pushed down to the
sibling `CLAUDE.md` that loads where that work happens — the root keeps
a one-line pointer.

**Relocations** (substance preserved at the destination, root keeps a
pointer):

- Testing commands, the `mock-stub` filtered-run gotcha, Claude Code web
setup, the shell/PTY nextest SIGTTOU suspension, and codecov
investigation mechanics → `tests/CLAUDE.md`
- Doc sync taxonomy (the three categories + PRIMARY SOURCE),
three-context help-text authoring, the `.gitattributes`
`linguist-generated=false` exemption, config-TOML double-comment rule →
`docs/CLAUDE.md`
- Plugin layout, the Codex no-hooks re-enablement conditions, the
accepted `wt-switch-create` tradeoff → `plugins/worktrunk/CLAUDE.md`
(its back-pointers into the root were made self-contained so nothing
dangles)
- The add-a-CLI-command recipe → `src/commands/CLAUDE.md`

**Kept in the root, tightened but substance intact:** the governing
invariants — Data Safety, the Command Execution Principles
(`shell_exec::Cmd`, structured output, network policy, signal handling),
Project Commands Run Only After Approval (verbatim — the #2806 TOCTOU
fix depends on its wording), the codecov merge gate, Config Deprecation,
accessor naming, and the Worktree Model.

A third commit consolidates the doc-sync explanation in
`docs/CLAUDE.md`: the new "Doc sync taxonomy" section was overlapping
with the file's existing "Command documentation" area on "edit
`src/cli/mod.rs`" and `test_docs_are_in_sync`. Now the taxonomy is the
single entry point and "Command page generation" reads as the category-1
mechanism details.

Every CLAUDE.md heading quoted in a code comment is preserved verbatim
so the breadcrumbs still resolve under a case-sensitive grep: `Plugin
Layout` (`src/commands/config/codex.rs`), `Project Commands Run Only
After Approval` (`src/commands/hook_plan.rs`,
`src/commands/picker/mod.rs`), `Network Access`
(`src/git/repository/config.rs`), and `Signal Handling`
(`src/git/error.rs`, `src/commands/run_pipeline.rs`,
`src/commands/command_executor.rs`). The first push downcased the latter
two; the automated reviewer caught it and the second commit restored
Title Case across all Command Execution Principles subheadings.
`test_docs_are_in_sync` passes (docs-only change; no code touched).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:09:50 -07:00
Maximilian Roos 408f4f5bee Add wt step tether: kill a command's process group when its worktree is removed
`wt step tether -- CMD…` runs CMD in its own process group and tears the whole
group down when CMD exits or its worktree is removed (a 250ms portable poll;
killpg on Unix, taskkill /T /F on Windows). Replaces the leaked-dev-server /
fseventsd-saturation failure mode with a fire-and-forget supervisor needing
only a single post-start hook. No unsafe, no new deps. Shell handling matches
`wt step for-each`. Windows taskkill has a documented self-exit-detach edge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 11:57:16 -07:00
Maximilian Roos 7d09c5da58 fix(merge): gate commit-append separately so declining it keeps hooks (#2802)
## Summary

`wt merge` bundled the project commit-append into the hook-approval
batch and shadowed `verify = false` on *any* decline. When the project
hooks were already approved (and thus filtered out of the prompt),
declining the lone commit-append prompt skipped every
pre-merge/post-merge hook for that run — even though the user only meant
to skip the append. This was flagged as a follow-up during review of
#2774.

The append is now resolved through the shared
`approve_commit_template_append` gate — the same path `wt step commit` /
`wt step squash` already use — so its decline drops only the append and
never touches hook approval. Hooks are approved on their own; only a
hook decline skips hooks.

Trade-off: when both project hooks *and* the append are unapproved on a
fresh repo, the user now sees two prompts instead of one bundled prompt.
This matches the standalone commit/squash flow and is the canonical
behavior; the old single-prompt bundling was what introduced the
conflation. Already-approved appends still don't re-prompt.

## Also in this PR

- **Message canonicalization** — `merge`, `removal`, `prune`, and
`switch` now print `Commands declined, … without hooks`, matching the
wording `step squash` / `step commit` already used. `wt hook` and `wt
config approvals add` stay bare (they end on decline rather than
continuing an operation).
- **Docs** — the hook Security section now states that declining skips
every project command for that operation (already-approved ones
included) and that saved approvals are unaffected. Synced to the skill
reference.
- **Doc-comment fixes** — `HookGate` / `PreApprovedGuidance` comments no
longer describe the removed bundled-prompt flow.

## Testing

- New regression test `test_merge_decline_append_keeps_approved_hooks`:
with the hook pre-approved, declining the append still runs the
pre-commit hook and the merge succeeds (fails on the old code, passes
now).
- `test_merge_bundles_append_into_hook_approval` renamed to
`test_merge_prompts_hooks_and_append_separately`, updated for the
two-prompt flow.
- Four decline snapshots regenerated (message text only).
- Full pre-merge gate green: 3736 tests, clippy, fmt, doc-sync.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 11:42:04 -07:00
Worktrunk Bot 2ca0113a04 docs: remove unverified cmux recipes from tips-patterns (#2797)
## Problem

The cmux recipes in `docs/content/tips-patterns.md` don't work as
documented — see #2796. `wt switch` cd's the invoking shell *in addition
to* the `pre-switch` hook selecting the per-worktree cmux workspace, so
the original cmux workspace ends up pointing at the new worktree
alongside the freshly-opened one. The Agent-handoffs cmux entry has a
similar pollution issue.

I initially proposed adding `[switch] cd = false` (prior revision of
this PR), but I couldn't end-to-end verify the fix because cmux isn't
installable in this CI environment. Per @max-sixty's call ([#2796
(comment)](https://github.com/max-sixty/worktrunk/issues/2796#issuecomment-4478578957))
— *"let's just remove the cmux recipes unless someone can certify that
they work"* — the safer move is to drop the recipes until someone with
cmux installed can re-add a verified configuration.

## What this PR does

Removes both cmux blocks from `docs/content/tips-patterns.md` (and the
auto-synced skill reference):

- The `**cmux** (new workspace):` entry under **Agent handoffs**.
- The full `## cmux workspace per worktree` section.

Diff is the inverse of #1907 (the PR that added them): 33 + 36 = 69
deletions.

The tmux and Zellij Agent-handoffs examples are unaffected.

## Testing

- `cargo test --test integration test_docs_are_in_sync` ✓ (regenerated
`skills/worktrunk/reference/tips-patterns.md`)
- `git grep -ni cmux` returns no matches.

---
Closes #2796

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:08:02 -07:00
dependabot[bot] 3848a9da7a chore: bump the patch group with 3 updates (#2792) 2026-05-17 23:13:15 -07:00
Maximilian Roos f522357e11 feat(commit): experimental project-level commit-message guidance (#2774)
## Summary

- Adds `[commit.generation] template-append` to **both** user config and
project config (`.config/wt.toml`). It *adds to* the commit/squash
prompt rather than replacing it (`template` still replaces). One field
covers both commit and squash.
- Each fragment is rendered as its own minijinja template with the same
variable context as the main template, then emitted in a
provenance-labeled block: `<user-guidance>` (the developer's own config
— no approval) followed by `<project-guidance>` (repo-supplied — gated).
Default templates render each block conditionally; `{{ user_guidance }}`
/ `{{ project_guidance }}` are exposed for custom user templates.
- The project fragment keeps the first-time approval gate — same
one-shot gate as project hooks. `wt merge` bundles it into the existing
hook-approval batch so the user sees one combined prompt. Declining is
non-fatal; the LLM runs with just the user fragment (if any). Skipped
automatically when no LLM command is configured.
- Marked experimental in CLI help, docs, and the user/project config
docstrings.

Discussion: #2758 (comment
[4454619614](https://github.com/max-sixty/worktrunk/issues/2758#issuecomment-4454619614))

## Test plan

- [x] Unit tests for `commit_template_append()` and the user-side
accessor (trim / empty / unset)
- [x] `Merge` test for user `template-append`
- [x] Snapshot tests confirming the default commit and squash templates
render the `<project-guidance>` block
- [x] Unit tests for user-side append: renders into `<user-guidance>`,
ordered before `<project-guidance>`, blank-is-unset, minijinja variable
expansion
- [x] PTY integration tests for the approval flow (accept / decline /
merge bundling), asserting `<project-guidance>`
- [x] `cargo run -- hook pre-merge --yes` — 3708 tests passing, clippy +
pre-commit clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 17:19:19 -07:00
Maximilian Roos 233cd493d0 fix(codex): drop activity-marker hooks Codex can't drive (#2786)
The Codex plugin shipped `hooks.json` with `SessionStart`→💬,
`UserPromptSubmit`→🤖, and `Stop`→💬. But codex-cli 0.130.0's
`HookEventNameWire` schema (extracted from the binary) has **no
`Stop`/turn-end event**: `PreToolUse`, `PermissionRequest`,
`PostToolUse`, `PreCompact`, `PostCompact`, `SessionStart`,
`UserPromptSubmit`. So on Codex the 🤖 marker set at `UserPromptSubmit`
could never return to 💬 — a Codex worktree stuck at "working" for the
entire session. That's worse than the docs claimed ("rests at 💬").

This removes the marker hooks entirely until Codex exposes a turn-end
hook event. The Codex plugin now ships only the configuration skill;
activity tracking joins worktree-isolation and `/wt-switch-create` as
Claude-Code-only. A re-enablement comment (exact conditions + the files
to restore) lives in `src/commands/config/codex.rs` and a new
`CLAUDE.md` → "Codex Plugin" section, which also documents the accepted
tradeoff that the `skills/` symlink exposes the Claude-only
`wt-switch-create` skill to Codex (harmless — Codex can't act on it).

Bundled adjacent fixes: `plugin.json` metadata drift
(`description`/`longDescription` no longer claim activity hooks;
`homepage`/`websiteURL` `https://worktrunk.dev/claude-code/` →
`https://worktrunk.dev`), and the install/uninstall/`--help`/docs text
made honest about Codex having only the configuration skill.

**Reviewer navigation:** `src/commands/config/codex.rs` +
`src/cli/config.rs` (runtime + `--help` text),
`docs/content/claude-code.md` (skill ref + `llms.txt` auto-synced),
`CLAUDE.md` (new section), `tests/integration_tests/config_show.rs`
(`test_codex_plugin_metadata_is_valid_json` now asserts `hooks` absent +
guards metadata regression). Snapshot env-block churn is stale-fixture
convergence to the repo's current `/nonexistent/wt/` convention, not a
leak.

**Testing:** Full pre-merge gate green locally (3704 tests, 0 skipped;
lints clean). The metadata test was strengthened; the marker behavior
itself is removed, so there's nothing runtime-testable to add.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:29:54 -07:00
Maximilian Roos f00b787e1b fix(codex): relocate plugin into subdir so the marketplace resolves it (#2782)
## Problem

#2780 put `.agents/plugins/marketplace.json` at the path codex-cli
reads, but the plugin was still **uninstallable**. Driving the Codex
`/plugins` TUI surfaced the real, complete set of constraints in
codex-cli 0.130.0:

1. A plugin `source` must be the **object** form
`{"source":"local","path":"./plugins/<name>"}` — a bare string `"./"`
fails with `local plugin source path must not be empty`.
2. The path must be a **non-empty subdirectory**. Codex resolves it
relative to the marketplace root (repo root), so a repo-root plugin
(`./.codex-plugin/` at the top) can't be referenced at all — every Codex
marketplace plugin lives in
`./plugins/<name>/.codex-plugin/plugin.json`, exactly like the
OpenAI-curated marketplace.

So the plugin had to move into a subdirectory.

## Changes

- Move `.codex-plugin/` → `plugins/worktrunk/.codex-plugin/` and
`hooks/` → `plugins/worktrunk/hooks/` (a plugin-root sibling, matching
the curated layout where `skills/` and `assets/` sit beside
`.codex-plugin/`).
- `plugins/worktrunk/skills` → symlink to `../../skills` so the shared
`worktrunk` + `wt-switch-create` skills stay single-source (no
duplication; the repo still auto-syncs `skills/worktrunk/reference/`).
- Rewrite `.agents/plugins/marketplace.json` with the object `source`,
`policy.installation: "AVAILABLE"`, and `category` that Codex actually
parses.
- `plugin.json` `hooks` → `./hooks/hooks.json` (plugin-root relative).
- Update the install hint (`src/commands/config/codex.rs`), docs,
auto-synced skill reference, the validity test, and the install
snapshot.

## Verification

Verified end-to-end against codex-cli 0.130.0: repointed a local
marketplace at the worktree and drove `/plugins`. The `Worktrunk`
marketplace now appears (plugin count 123 → 124), the plugin shows as
**Available** with **both skills resolved** (`worktrunk:worktrunk`,
`worktrunk:wt-switch-create` — the symlink works), and it installs.

`test_codex_plugin_metadata_is_valid_json` now asserts the object
`source`, `policy.installation`, `interface.displayName`, and the new
paths. `test_docs_are_in_sync` and the install snapshot are updated;
full `config_show` module (119 tests) + pre-commit pass.

**Caveat (documented, not a regression):** plugin-bundled *command*
hooks remain gated by Codex's `plugin_hooks` feature flag — already
covered in the troubleshooting docs (`codex features enable
plugin_hooks`). The pre-install plugin detail view shows "No plugin
hooks", consistent with every one of the 123 curated plugins (none
bundle command hooks, so this path has no working precedent to verify
against in-TUI). Marker behavior with `plugin_hooks` enabled is the
remaining thing to confirm in a real installed session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:29:03 -07:00
Douglas Soares de Andrade 2c675e00f7 Add Codex support (#2512)
## What problem does this solve?

Worktrunk already has a Claude Code integration, but Codex users do not
have an equivalent first-class path for:

- discovering Worktrunk guidance inside the agent environment
- bundling hooks that can show active Codex work in `wt list`
- learning the Codex-specific worktree workflow and its differences from
Claude Code
- installing the integration through a Worktrunk CLI command

This PR adds Codex support while preserving the important semantic
difference between Claude Code and Codex: Claude Code can route
agent-created worktrees through `wt` lifecycle hooks, but Codex
currently needs users/agents to invoke `wt switch --create` and `wt
remove` directly.

## What changed?

### Codex plugin package

- Adds repo-local Codex plugin metadata in `.codex-plugin/plugin.json`.
- Adds a Codex marketplace entry in `.agents/plugins/marketplace.json`.
- Adds `hooks/hooks.json` for bundled activity hooks:
  - `SessionStart` sets the marker to `💬`
  - `UserPromptSubmit` sets the marker to `🤖`
  - `Stop` sets the marker back to `💬`
- Reuses the existing Worktrunk skill/reference docs as the plugin skill
payload.

### CLI integration

- Adds `wt config plugins codex install`.
  - Runs `codex plugin marketplace add max-sixty/worktrunk`.
- Clearly tells the user to open `/plugins` in Codex and install
Worktrunk from the marketplace.
- Adds `wt config plugins codex uninstall`.
  - Removes the Worktrunk marketplace entry.
- Intentionally leaves already-installed plugins and global Codex hook
feature flags unchanged, because users may have configured hook settings
for other hooks.
- Adds a `CODEX` section to `wt config show` when the Codex CLI is
available.
  - It reports Codex CLI availability.
- It avoids claiming the plugin is installed, because the CLI path only
detects the Codex binary.

### Documentation

- Adds a new Codex integration page with installation, bundled activity
hooks, worktree workflow, LLM commit setup, and a Claude Code
comparison.
- Updates README, overview docs, tips/patterns, FAQ, and generated skill
references so Codex is listed alongside Claude Code/OpenCode where
relevant.
- Keeps the Claude Code docs as the place for Claude-only worktree
lifecycle hook behavior.

## Why this shape?

The implementation keeps Codex separate from the existing Claude plugin
command instead of abstracting over both. That is intentional: Claude
installs a plugin directly, while the Codex flow configures a
marketplace and still requires the user to install from `/plugins`. A
shared abstraction would hide those differences and make the user-facing
behavior easier to misread.

The docs also avoid presenting Codex as feature-identical to Claude
Code. Codex gets skills and bundled activity hooks here, but not
automatic worktree lifecycle routing.

## Review guide

- Plugin packaging: `.codex-plugin/plugin.json`,
`.agents/plugins/marketplace.json`, `hooks/hooks.json`
- CLI behavior: `src/commands/config/codex.rs`, `src/cli/config.rs`,
`src/commands/config/show.rs`
- Test helpers and coverage: `src/testing/mod.rs`,
`tests/integration_tests/config_show.rs`,
`tests/integration_tests/help.rs`
- Primary docs: `docs/content/codex.md`
- Generated docs/snapshots: `skills/worktrunk/reference/codex.md`,
config/help snapshots

## Tests

- `cargo fmt --check`
- `cargo check --lib --bins`
- `cargo test --lib --bins`
- `cargo test --test integration test_docs_are_in_sync`
- `cargo test --test integration "test_help"`
- `cargo test --test integration config_show`
- `git diff --check HEAD`

## Local pre-merge note

`cargo run -- hook pre-merge --yes` could not complete locally because
this environment does not have `pre-commit` or `cargo-nextest`
installed. The hook's doctest/doc portions did run and passed.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-05-16 20:30:32 -07:00
Maximilian Roos 771739f6b7 perf(integration): bound patch-id squash-merge scan to 500 commits (#2752)
## Summary

`is_squash_merged_via_patch_id` runs `git log -p {merge-base}..{target}
| git patch-id` over the *entire* target-side history. The range is
unbounded: on a fast-moving repo with an old branch — tens of thousands
of commits since divergence — a single integration check takes seconds
to tens of seconds, which surfaces as `wt step prune` (and `wt list`)
going visibly silent on the parallel check tail in #1888.

This caps the scan at `PATCH_ID_SCAN_MAX_COMMITS = 500` commits via a
cheap graph-only `git rev-list --count` pre-flight. Above the cap, the
check returns `Ok(false)` — the safe direction (branch kept, not wrongly
deleted); `wt remove -D` still removes branches that fall past the cap.

## Why 500

Count is a rough proxy — per-commit cost scales with `changed_files ×
changed_lines`, not just count. Working back from "keep one check under
a few seconds":

- typical repo (~5-20 KB patches): 500 ≈ well under 1s
- heavy monorepo (~50-100 KB patches): 500 ≈ 2-5s

Branches merged within a normal review-and-cleanup cycle sit well inside
this. Anything older is `-D` territory.

## Scope

The cap is in the shared probe, so it applies to `wt list`'s status
column, `wt remove`, `wt merge`, and `wt step prune` — anywhere
`integration_reason` is called. Behavior change only when a branch was
squash-merged *and* `git merge-tree` conflicts (the same files were
modified again after the squash) *and* the default branch has advanced >
500 commits since the merge point. Narrow.

## Test plan

- Two new unit tests in `patch_id_cap_tests` using `git fast-import`
(501-commit history built in milliseconds): under-cap finds the squash,
over-cap bails despite the squash being in range. The pair pins the
difference to the cap, not the topology.
- Existing squash-merge integration tests
(`test_remove_squash_merged_*`, `test_prune_squash_merged_*`,
`test_list_integrated_when_squash_merged_*`) keep small histories well
under the cap and continue to pass — regression guard for the wiring.

Refs #1888.

@ortonomy — would you mind running this against the repo from #1888? The
simplest check is `cargo install --git
https://github.com/max-sixty/worktrunk --branch prune-speed wt` (or
build locally), clear the disk cache to force a cold run (`rm -rf
.git/wt/cache`), then time `wt step prune`. Most interested in whether
the silent stretch is gone; if it isn't, `wt -vv step prune` writes
`.git/wt/logs/trace.log` showing which `git` invocations are slow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:17:40 -07:00
Worktrunk Bot 4cfc8e6acd docs(extending): note alias template timing and how to defer to nested wt (#2754)
Addresses [#2753](https://github.com/max-sixty/worktrunk/issues/2753) —
adds a short subsection under Aliases documenting that templates render
at alias dispatch (using the alias-invocation worktree's context), so a
nested `wt` command's own template variables resolve against the outer
worktree unless wrapped in `{% raw %}…{% endraw %}`.

The example uses the reporter's pattern (`wt switch ... --execute 'echo
{{ worktree_path }}'`) verified against the release binary in a scratch
repo:

- Direct: `wt switch other --no-cd --execute 'echo {{ worktree_path }}'`
-> target path
- Aliased without `{% raw %}`: -> outer worktree path (the bug)
- Aliased with `{% raw %}{{ worktree_path }}{% endraw %}`: -> target
path

`test_docs_are_in_sync` propagates the change into
`skills/worktrunk/reference/extending.md`.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:56:58 -07:00
Maximilian Roos 52555982a8 feat(plugin): /wt-switch-create — optional repo arg and -- task delimiter (#2751)
The `/wt-switch-create` skill now accepts `<branch> [<repo>] [--
<task>]`. The optional second token names a different repository to
create the worktree in (instead of the session's current one); the skill
`cd`s into that repo with a `Bash` call before invoking `EnterWorktree`
(which has no repo parameter), then verifies the new worktree landed
under the requested repo as a safety check.

The `--` cleanly separates the task from the rest. Without one, the
parser treats a path-shaped second token (absolute, `~`-relative,
`./`/`../`-relative, or an existing directory) as the repo and the
remaining tokens as the task — anything else after the branch is task
text, so the old shorthand `/wt-switch-create my-branch fix the bug`
keeps working.

Examples in the skill:

```
/wt-switch-create my-feature -- fix the parser bug
/wt-switch-create my-feature ~/workspace/other-repo -- fix the parser bug
/wt-switch-create my-feature
```

Also tidied existing prose: the nesting + different-repo case now stops
rather than silently running the task in the wrong repo, and the scope
note mentions the repo arg.

The follow-up commit updates the public docs page
(`docs/content/claude-code.md`) so the `/wt-switch-create` signature
shown at worktrunk.dev matches the new grammar;
`skills/worktrunk/reference/claude-code.md` re-synced via
`test_docs_are_in_sync`.

No code or test changes.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:49:27 -07:00
Maximilian Roos 761c293483 chore(plugin): drop off-spec version key, align skill frontmatter (#2747)
Tidies the plugin skills' YAML frontmatter against the [Agent
Skills](https://agentskills.io/specification) spec, and cleans up a
release-skill step the change makes stale.

- `skills/worktrunk/SKILL.md`: drop the top-level `version: 0.49.0` key.
The spec only defines `name`, `description`, `license`, `compatibility`,
`metadata`, `allowed-tools` at the top level (version, if kept, belongs
under `metadata`), and Claude Code's own field set doesn't include
`version` either. Also drop the
`[package.metadata.release].pre-release-replacements` entry in
`Cargo.toml` that existed solely to bump that line on release.
- `skills/wt-switch-create/SKILL.md`: add `license` and `compatibility`
so both plugin skills carry the same spec-valid metadata.
- `.claude/skills/release/SKILL.md`: step 7 no longer applies
`pre-release-replacements`, and `index.json`'s `SKILL.md` digest no
longer goes stale on release (`cargo release` doesn't touch `SKILL.md`
anymore) — drop the parenthetical and the now-no-op digest-resync
sub-step.
- Both `compatibility` lines now say "the `wt` CLI" rather than
"worktrunk CLI".
- `docs/static/.well-known/agent-skills/index.json` digest regenerated
by `test_docs_are_in_sync`.

Follow-up to #2737 / #2745. No `.rs` changes.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:48:37 -07:00
Worktrunk Bot 3c04588ccf docs: refresh stale path references in docs/CLAUDE.md (#2739) 2026-05-12 07:46:24 +00:00
Maximilian Roos 5a07bc09bd feat(plugin): ship the wt-switch-create skill in the Claude Code plugin (#2737) 2026-05-11 21:10:35 -07:00
Maximilian Roos 12e2ee2563 docs: drop stale docs/dev/cache-staleness.md (#2710)
The doc proposed and analyzed six options for closing the ref-keyed
cache staleness class; the codebase adopted Option 5 (`RefSnapshot`) in
#2528 and extended it with the content-addressed `ahead-behind/` SHA
cache in #2704. The structural contract now lives in
`src/git/repository/ref_snapshot.rs` (snapshot semantics, lifetime,
construction) and the cache-kind landscape in
`src/commands/list/collect/mod.rs`'s Caching section, both of which
supersede what was in this file.

What remained in the doc actively misleads: its `RepoCache` inventory
table cites fields that no longer exist (`commit_shas`, `tree_shas`,
`effective_integration_targets`, `integration_reasons`, `ahead_behind`,
`head_shas`) and references a `batch_ahead_behind` helper that's been
replaced. The file was an island — no inbound links from code, tests, or
other docs, and `docs/dev/` is not a Zola content directory so it was
never published — so deletion is clean. Anyone curious about the
original design debate can find it at the merge commit for #2528.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 11:18:40 -07:00
Maximilian Roos 0b088b5790 perf(list): cache ahead/behind counts SHA-keyed; skip the for-each-ref walk on warm runs (#2704)
## Summary

`wt list --branches` computed branch ahead/behind counts with one `git
for-each-ref --format='%(ahead-behind:BASE)'` walk, on every invocation,
in the post-skeleton setup scope. That walk is O(commits) — ~1.5s on
rust-lang/rust — and it runs *serially before the parallel task pool
opens*, because the per-branch tasks consume its result. So on a large
real repo roughly 40% of `wt list`'s wall time was a single-threaded git
graph walk that thread count couldn't help with (a follow-up from the
picker thread-count investigation in #2683/#2685).

Ahead/behind for `(base, branch)` is a pure function of the two commit
SHAs — content-addressed, never stale — exactly the shape of the
existing `.git/wt/cache/` SHA-keyed caches. This adds an `ahead-behind/`
cache kind and rewires the population path:

- New `ahead-behind/{base_sha}-{head_sha}.json` kind in `sha_cache.rs`,
identical machinery to `is-ancestor` / `diff-stats` (LRU-bounded,
cleared by `wt config state clear`, wiped by the bench
cache-invalidator).
- `Repository::ahead_behind_by_sha` is now cache-backed: check →
`compute_ahead_behind` → write.
- `RefSnapshot::capture_refs_with_ahead_behind` builds the snapshot's
ahead/behind map from the cache and only walks the graph for the
branches the cache doesn't cover:
- **a few misses** → left out of the snapshot map; the per-branch
`AheadBehindTask` recomputes (and caches) them by SHA in the parallel
pool. (That task already runs `merge_base_by_sha` for its orphan check,
so the merge-base `compute_ahead_behind` needs is already primed — the
fallback is just two cheap `rev-list --count` calls.)
- **everything cold** (fresh repo / after `wt config state clear`) → one
unscoped `for-each-ref %(ahead-behind:BASE_SHA) refs/heads/` walk — a
single shared traversal finds every merge-base — then results written to
the cache.
- **many cold but not all** → one `for-each-ref
%(ahead-behind:BASE_SHA)` *scoped to just the missed refnames* (`git
for-each-ref` takes a ref list, so it's still one shared traversal, only
over the cold subset), results written to the cache.

The few-vs-many threshold (`AHEAD_BEHIND_SCOPED_BATCH_MIN_MISSES`)
trades the per-pair path's parallelism (it runs in the pool) against the
batch's lower total work (one shared base-history traversal); a batch
here is serial, so it's only worth it once "many" misses make the
amortization clear.

### Cache-correctness details (Codex-prompted)

A few sharp edges, surfaced by `/review-codex`, that the seeding path
now handles explicitly:

- **`%(ahead-behind)` computed against the *resolved SHA*, not the
refname.** A tag named `main` shadows the branch in git's ref
resolution; passing `base_sha` to the batch makes it inert to that. The
fallback path (default branch isn't a local/remote-tracking ref) still
passes the refname and caches nothing.
- **Cache key from the batch's `%(objectname)`, not a separately-scanned
value.** If a `refs/heads/X` moved between `scan_locals` and the batch
(a sub-ms window, but possible), the counts are for the *new* SHA — and
the cache now stores them under that SHA, not under the staler
`b.commit_sha`.
- **Orphan branches normalized to `(0, 0)` before writing.** Git's
`%(ahead-behind:BASE)` for an orphan (no common ancestor) prints the two
disjoint history sizes; `compute_ahead_behind` returns `(0, 0)` and
signals orphan-ness separately. An orphan's `behind` count equals base's
total commit count, so one `git rev-list --count base_sha` on the
seeding path detects them. The cache invariant — cache hit equals what a
miss would recompute — holds for orphans now.
- **Bulk-write avoids O(N · dir_size) on the seeding path.**
`write_with_lru` scans the cache directory on every call to enforce the
LRU bound; the serial setup-scope seeding loop calls it N times.
`put_ahead_behind_bulk` writes all N entries first and sweeps once at
the end.

### Net effect

| | before | after |
|---|---|---|
| warm `wt list` on a large repo | ~1.5s serial `for-each-ref
%(ahead-behind)` in the setup scope | N small cache reads; no graph
walk; no serial blocker |
| `wt list` after committing on one branch | full ~1.5s walk again
(batch op — any change re-walks all) | only that branch recomputed (two
cheap `rev-list --count`, in the pool); the rest stay cached |
| many branches moved since last list | full walk | one `for-each-ref
%(ahead-behind)` scoped to just the moved ones |
| cold (fresh repo / after `state clear`) | one combined walk |
unchanged — one combined walk + one bulk cache seed |
| skeleton time (`WORKTRUNK_SKELETON_ONLY`) | — | unaffected (returns
before the scope this touches) |

No user-facing behavior change — same numbers, same columns. The picker
(`wt switch`) shares this path, so its preview pre-compute benefits
identically.

### Follow-ups left as TODOs in the code

- `TODO(ahead-behind-pool)` (`src/commands/list/collect/mod.rs`): the
cold-cache `%(ahead-behind)` walk still runs serially in the setup
scope, blocking the task pool from opening. Nothing downstream of
work-item generation needs the *counts* (only the per-row
`AheadBehindTask`, which has a per-SHA fallback) — only the cheap ref
scan does. So the walk could become a single work item in the pool,
overlapping the other ~N workers. Needs an inter-task dependency the
work-item model doesn't have today.
- `TODO(remote-ahead-behind-batch)`
(`src/commands/list/collect/tasks.rs`): the `Remote⇅` column's
per-branch `ahead_behind_by_sha(upstream, branch)` is already
cache-backed by this change, but has no cold-start batch primer. One
`for-each-ref --format='%(refname) %(upstream:track,nobracket)'
refs/heads/` would fill the `ahead-behind/` cache for it in a single
walk, mirroring what `%(ahead-behind:main)` does for `main↕`.

## Testing

- `sha_cache.rs`: round-trip + cache-read tests for the new kind;
`test_clear_all_covers_all_kinds` extended.
- `ref_snapshot.rs`: all-cold→unscoped-batch + cold→warm second-capture
reads the persistent cache; partial-warm (few misses) omits the moved
branch; many-misses uses the scoped batch; **orphan normalizes to
`(0,0)` in both the snapshot and the cache file**; remote-tracking base
resolves; unresolvable base degrades cleanly.
- Smoke-tested manually: `wt list --branches` populates
`.git/wt/cache/ahead-behind/`; tampering an entry → the next run shows
the tampered counts (proves the read path).
- Two passes of `/review-codex` — second pass clean.
- `cargo run -- hook pre-merge --yes` — full suite + lints + doc-sync
(3595 tests).

## Out of scope (flagged, not done here)

`docs/dev/cache-staleness.md` still references the in-memory
`RepoCache.ahead_behind` field and `batch_ahead_behind` — both removed
when `RefSnapshot` landed. Pre-existing staleness; rewriting that doc is
a separate cleanup. (The pre-skeleton `git log --no-walk` commit-details
batch is also content-addressed by SHA and could in principle be
cache-first, but it's already O(refs) and fast — not worth a TODO.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:27:15 -07:00
Maximilian Roos 00c4508671 refactor(ci): move CiPlatform to the lib crate, cache on RepoCache, drop the "override" framing (#2692)
Follows up on #2686 (the original "dedup the warning" fix), which parked
the configured-platform state in a process-wide `OnceLock` because
`CiPlatform` lived in the binary crate and `RepoCache` in the library
crate.

**Move `CiPlatform` to the lib crate.** It's now
`worktrunk::git::CiPlatform` (a new `src/git/ci_platform.rs` module),
with `Repository::ci_platform(remote_hint)` replacing the free
`platform_for_repo` and a private `Repository::configured_ci_platform()`
reading `forge.platform` / `ci.platform`. The configured value caches on
a `OnceCell` field of `RepoCache` instead of a process global — same
once-per-`wt list` warning dedup, but the repo-scoped data no longer
lives in a process static. `src/commands/list/ci_status/platform.rs` is
now pure forge dispatch (`gh` / `glab` / `az`) over a `CiPlatform`; the
inherent `detect_*` methods became free functions so the enum could move
without dragging the bin-crate backends along.

**Drop the "override" framing.** `forge.platform` / `ci.platform` is now
described everywhere as "the platform, falling back to URL detection
when unset" rather than "overriding detection" — config docs (`## Forge
platform`), the `ProjectForgeConfig` / `ProjectCiConfig` struct and
accessor docs, `wt config state ci-status` help, and a few test names
(`test_list_full_with_configured_platform_github`,
`test_list_full_with_invalid_configured_platform`,
`test_switch_pr_gitea_forge_platform`). Genuine "override" uses
elsewhere (config-path/env-var overrides, `wt config state
default-branch set`) are untouched.

**Fix a pre-existing bug.** `forge.platform = "gitea"` is a valid value
— the `wt switch pr:` shortcut uses it to pick `tea` — but worktrunk
fetches CI status only from GitHub/GitLab, so `wt list` used to print
`Invalid CI platform in config: 'gitea'. Expected 'github' or 'gitlab'.`
for it. `configured_ci_platform()` now recognizes `gitea` as a known
forge it just doesn't show CI for: no warning, blank CI column. Added
`test_list_full_with_gitea_forge_platform` (asserts blank CI and empty
stderr).

Branch is merged with `main`, so the dispatch and detection handle the
new `AzureDevOps` variant alongside GitHub and GitLab.

`cargo run -- hook pre-merge --yes` passes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:12 -07:00
Maximilian Roos 014765252f feat(config): bare wt config alias show lists full definitions (#2691)
## Summary

`wt config alias show` with no name showed a compact one-line summary
listing (the `name command-summary (source)` table added in #2684). This
swaps it for the full per-alias view: the same `○ Alias <name>
(<source>):` header + gutter block that `wt config alias show <name>`
prints, emitted for every configured alias in name order (user entry
before project when a name is defined in both), with the
shadowed-by-built-in stderr warning preserved (once per name). `--help`
/ `wt step --help` keep the compact names-only `Aliases:` section and
point here.

```
○ Alias landed (user):
  wt up && wt hook post-merge; git merge {{ default_branch }} --no-edit && { git
   diff {{ default_branch }} --quiet && git reset {{ default_branch }}; :; }
○ Alias sw (user):
  wt switch {{ args }}
○ Alias up (user):
  git fetch --all --prune && wt step for-each -- sh -c '
    ...
  '
```

Also drops the blank line between consecutive `format_entry` blocks —
applied consistently to `wt config alias show` (the new listing), `wt
config alias show <name>` (had a blank line between the user and project
entries when a name is in both configs), and `wt config alias dry-run
<name>` (same). The `○ Alias …` header is the delimiter everywhere.

The compact-summary renderer (`render_aliases_section` +
`format_alias_summary`) and its unit tests are removed — only #2684's
no-arg listing used them; `--help`'s names-only section is the separate
`render_aliases_help_section`, which is untouched.

## Changes

- `src/commands/config/alias.rs` — `list_aliases()` rewritten to emit
`format_entry` per alias; `handle_alias_show` / `handle_alias_dry_run`
drop the inter-entry blank line; docstrings + module doc updated.
- `src/commands/alias.rs` — delete `render_aliases_section`,
`format_alias_summary`, and their tests; tidy the
`render_aliases_help_section` doc.
- `src/cli/config.rs` — `wt config alias show` help text reflects the
new behavior.
- Docs (`docs/content/config.md`, skill mirror) regenerated; three
snapshots updated (one content change + cosmetic env drift on two).

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — 3581 tests pass, all lints
green
- [x] Manual: `wt config alias show`, `wt config alias show <name>`, `wt
config alias dry-run`, `wt config alias show --help`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 00:16:12 -07:00
mikeyroush 1ca73ab52c feat: experimental Azure DevOps support (#1256)
## Summary

Adds **experimental Azure DevOps support** alongside the existing GitHub and GitLab integrations:

- `wt switch pr:<N>` resolves Azure DevOps PRs via the `az` CLI (auto-detected from `dev.azure.com` / `ssh.dev.azure.com` / `*.visualstudio.com` remotes, or pinned via `[forge] platform = "azure-devops"`).
- `wt list --full` surfaces Azure DevOps PR and pipeline CI status.
- `wt config show --full` reports `az` install/auth state when Azure DevOps is the detected platform.

GitHub still wins in mixed-remote setups; `forge.platform` is the override. Requires the `azure-devops` CLI extension (`az extension add --name azure-devops`).

## Context

Originally proposed by @mikeyroush; reimplemented against current `main` to pick up the `[forge]` config section, `url.insteadOf` fallback, the `handle_switch` consolidation, and the new Gitea provider that all landed after the original branch was opened. The dispatch path (`choose_pr_provider`) is shared with GitHub/Gitea — Azure is just a fourth provider in the same priority chain.

Implementation notes worth a reviewer's attention:

- Azure DevOps URLs don't fit the standard `host/owner/repo` shape (`dev.azure.com/{org}/{project}/_git/{repo}`), so `Repository::find_remote_for_azure` matches on `org` + `project` + `repo` instead of the owner-based path used for the other forges.
- Pipeline/PR web URLs are constructed from `org/project/build-id`, not the API's `url` field (which is a REST endpoint).
- `*.visualstudio.com` legacy hosts encode the org in the hostname; the URL helpers handle both shapes.

Fixes #1144

## Test plan

- `cargo run -- hook pre-merge --yes` — 3631 tests pass, clippy + fmt clean
- Unit tests cover the host-aware URL helpers, `find_remote_for_azure` (all URL shapes + the same-org/different-project collision case), and `choose_pr_provider` dispatch
- Integration tests bring Azure to parity with the other forges (see Coverage):
  - 13 `test_switch_pr_azure_*` tests mirroring the Gitea suite — same-repo, fork, `*.visualstudio.com` host, create/base conflicts, not-found, az-not-installed, `forge.platform` override, invalid JSON, generic server error, auth error, missing `azure-devops` extension, undeterminable org/host
  - 9 `test_list_full_with_azure_*` tests covering `detect_azure_pr` (conflicts, queued, stale, retriable error) and `detect_azure_pipeline` (passed/failed/running, stale, no runs, retriable error)
- Manual validation: `wt switch pr:<N>` and `wt list --full` against an Azure DevOps repo

## Coverage

The `az`-shelling code (`fetch_pr_info`, `detect_azure_pr`, `detect_azure_pipeline`) is now exercised by integration tests via new `setup_mock_az*` helpers (modeled on `setup_mock_gh` / `setup_mock_glab`) — covering the happy paths plus the not-found / auth / extension-missing / generic-error / retriable-error branches. The non-`az` parts (URL parsing, provider dispatch, remote matching) remain unit-tested.
2026-05-10 23:41:52 -07:00
Maximilian Roos a3733dcce8 feat(config): list all aliases when wt config alias show has no name (#2684)
`wt config alias show <name>` shows one alias's configured template.
With no name it now lists every configured alias — the same `Aliases:`
block `wt --help` renders (name, source, one-line summary). This is the
receiving end of a separate change that replaces the inline aliases
block in `wt --help` with a pointer to `wt config alias show`.

Implementation: `name` becomes optional with a single internal branch —
no `--list` or `--all` flag. The listing reuses `render_aliases_section`
/ `load_aliases_for_listing` from `commands/alias.rs` (promoted to
`pub(crate)`) rather than duplicating. It tolerates running outside a
repo (user-config aliases still list, project-config ones are skipped)
or outside a config, prints `No aliases configured` when empty, and
pages like `wt hook show`. Top-level shadowing is annotated (`list` →
`(shadowed by built-in)`).

Audit of other `show`/`view`/`info`/`print` subcommands found nothing
else to change: `wt hook show [TYPE]` already does this, `wt config
show` / `wt config shell show-theme` / `wt config state get` take no
positional and already show everything, and no `view`/`info`/`print`
subcommands exist.

Help text and the auto-synced docs/skill reference are updated; new
snapshot tests cover the populated and empty listings, and the four
`config_alias_show_unknown_*` snapshots pick up the `Usage: ... [NAME]`
change.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:42:06 -07:00
Steve Beaulac 7fca05441e feat(switch): experimental Gitea PR support via pr: shortcut (#1320)
Add experimental Gitea PR support to `wt switch pr:<number>`.

The `pr:` syntax already resolved GitHub PRs; this teaches it to also
resolve Gitea PRs via the `tea` CLI. GitLab continues to use `mr:`.

## Dispatch

`pr:N` now goes through `choose_pr_provider`:

1. `[forge] platform` in `.config/wt.toml` if set (`github` / `gitea` /
`gitlab`)
2. Primary remote URL detection (host contains `github` / `gitea` /
`gitlab`)
3. CLI auth lookup: if `tea` is configured for this host (per
`~/.config/tea/config.yml`) but `gh` is not (per `gh auth token
--hostname <host>`), pick Gitea
4. Default to GitHub

There is no longer an "ambiguous" fallback that tries both providers and
wraps both errors — users on self-hosted Gitea instances either run `tea
login add <host>` (auto-detected) or set `[forge] platform = "gitea"`.

## New code

- `src/git/remote_ref/gitea.rs` — `GiteaProvider` implementing
`RemoteRefProvider` via `tea api repos/<owner>/<repo>/pulls/<n>`; reuses
the shared `cli_api_error` / `run_cli_api` helpers.
- `src/git/remote_ref/info.rs` — `PlatformData::Gitea { host,
head_owner, head_repo, base_owner, base_repo }`, wired into
`source_ref()`, `prefixed_local_branch_name()`, and `find_remote()`.
- `src/git/url.rs` — `GitRemoteUrl::is_gitea()`.

Shared helpers introduced in this PR:

- `mod.rs::extract_host_from_html_url()` (used by github + gitea;
identical 7-line chains collapsed).
- `github::is_authed_for()` (wraps `gh auth token --hostname`).
- `gitea::is_authed_for()` (reads tea's config.yml; never invokes `tea`
to avoid OAuth refresh on lookup).

## Docs

User-facing copy says "GitHub PR" by default; one paragraph in `wt
switch --help` mentions Gitea support, marked experimental.

## Tests

- 13 new integration tests covering Gitea same-repo, fork, error
responses (401/403/404/5xx/malformed JSON/deleted fork/no source
branch), `tea` not installed, `forge.platform` overrides,
GitLab-remote-with-`pr:` bail, self-hosted defaults-to-GitHub, and
self-hosted-with-`tea`-login routes-to-Gitea.
- Unit tests for `extract_source_branch` edge cases and the tea config
parser.

## Compatibility

No CLI flag or config file changes. The `tea` CLI is only required for
Gitea PRs; GitHub-only users see no change.

---------

Co-authored-by: worktrunk-bot <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-05-10 20:47:31 -07:00
Worktrunk Bot dc3142714c docs(step): match the eval --dry-run separator to actual output (#2666) 2026-05-10 08:18:15 +00:00
Worktrunk Bot a77c92e25b docs(help): point banner at the actual cli source path (#2665) 2026-05-10 08:14:37 +00:00
Maximilian Roos 7a61d38348 Release v0.49.0 (#2660)
Bump worktrunk to **0.49.0** — minor bump (semver-checks reports a
breaking change in `GitError::UncommittedChanges`, which gained a
`dirty_files: Vec<String>` field).

## Highlights

**Improved**
- New `codename(n)` template filter — deterministic friendly worktree
names from a ~1.26M-combo pool (`{{ branch | codename(2) }}` →
`malleable-opah`).
[#2641](https://github.com/max-sixty/worktrunk/pull/2641), thanks
@endigma
- Picker preview disk cache for Log / BranchDiff / UpstreamDiff, with
background refresh for stale Log decorations.
[#2628](https://github.com/max-sixty/worktrunk/pull/2628),
[#2646](https://github.com/max-sixty/worktrunk/pull/2646)
- Picker shares one `default_branch_sha` lookup across BranchDiff items
instead of forking N rev-parse subprocesses.
[#2658](https://github.com/max-sixty/worktrunk/pull/2658)
- "X has uncommitted changes" errors now list the dirty files inline.
[#2653](https://github.com/max-sixty/worktrunk/pull/2653)

**Fixed**
- `wt switch` integrates with `cd` aliases like zoxide (uses `builtin
cd`). [#2644](https://github.com/max-sixty/worktrunk/pull/2644), thanks
@xkumiyu for reporting
- Empty hook tables no longer panic during `wt switch --create`.
[#2635](https://github.com/max-sixty/worktrunk/pull/2635), thanks @topit
for reporting
- `Repository::current_worktree()` and five sibling helpers no longer
silently leak the process CWD when called via `Repository::at(p)`.
[#2652](https://github.com/max-sixty/worktrunk/pull/2652)
- `WorkingTree::is_linked` tolerates non-git CWDs (e.g. Nix sandbox).
[#2625](https://github.com/max-sixty/worktrunk/pull/2625), thanks
@DArtagan for reporting
- Picker no longer overlays `collect` warnings on the active TUI; clears
its frame on exit in inline mode.
[#2627](https://github.com/max-sixty/worktrunk/pull/2627),
[#2626](https://github.com/max-sixty/worktrunk/pull/2626)
- Failed `git log` no longer poisons the picker's preview disk cache.
[#2651](https://github.com/max-sixty/worktrunk/pull/2651)
- `wt step prune` says "removing branch" for branch-only candidates.
[#2619](https://github.com/max-sixty/worktrunk/pull/2619)
- Claude Code Windows integration uses a `wt.sh` wrapper to avoid
colliding with Windows Terminal's `wt.exe`.
[#1754](https://github.com/max-sixty/worktrunk/pull/1754), thanks
@lucaspimentel

**Documentation**
- cmux workspace integration recipe.
[#1907](https://github.com/max-sixty/worktrunk/pull/1907), thanks
@alvistar

See
[CHANGELOG.md](https://github.com/max-sixty/worktrunk/blob/release/CHANGELOG.md)
for the full list including internal CI / test-isolation changes.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (tests + lints clean)
- [x] `cargo semver-checks check-release -p worktrunk` (one breaking
change confirmed → minor bump)
- [x] CHANGELOG entries verified by subagent against actual diffs

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-10 00:16:53 -07:00
Jesse ecdb7bfc35 feat(config): add codename template filter (#2641) 2026-05-09 07:46:56 -07:00
alvistar 404bd563a5 docs: add cmux workspace integration recipe (#1907)
## Summary

- Adds cmux to the **Agent handoffs** section alongside tmux and Zellij
- Adds a dedicated **cmux workspace per worktree** recipe with
create/select/close lifecycle hooks
- Documents the key gotcha: cmux socket restricts access to processes
with cmux terminal ancestry, so `pre-*` hooks must be used instead of
`post-*`

## Context

[cmux](https://cmux.com) is a macOS terminal built on libghostty with
workspace management via a Unix socket API. This recipe wires up
worktrunk hooks so each worktree automatically gets its own cmux
workspace.

The `pre-*` vs `post-*` distinction was discovered through debugging:
`post-*` hooks are detached background processes that lose the cmux
process ancestry, causing the socket to reject connections with "Access
denied — only processes started inside cmux can connect."

## Test plan

- [x] Tested full lifecycle: `wt switch -c`, `wt switch`, `wt remove` —
all correctly create, select, and close cmux workspaces
- [ ] Verify docs render correctly on worktrunk.dev (Zola build)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com>
2026-05-06 10:54:51 -07:00
Maximilian Roos f897aa7a9c Release v0.48.0 (#2618)
## Summary
Release v0.48.0. See
[CHANGELOG.md](https://github.com/max-sixty/worktrunk/blob/release/CHANGELOG.md)
for the full notes.

Highlights:
- `--format=json` extends to seven step + hook commands (#2560)
- `wt step commit` / `wt step squash` gain `--dry-run` (#2557)
- New `dirname` / `basename` template filters (#2592, #2605)
- New `[remove] delete-branch` config option (#2589)
- `wt-perf timeline` subcommand (#2558)
- Faster `wt list` on dirty worktrees (#2602) and faster alias dispatch
(#2556, #2573)
- Short-SHA display honors `core.abbrev` (#2576)
- Cleaner `wt config show` shell-integration section for new users

## Test plan
- [x] `cargo run -- hook pre-merge --yes` (3497 tests, lints clean)
- [x] `cargo semver-checks check-release -p worktrunk` consulted; minor
bump confirmed
- [ ] CI green
2026-05-04 23:32:56 -07:00
Maximilian Roos 33db25618a docs(skill): document parallel sub-Agent worktree pattern (#2600)
## Summary

Adds a subsection under *Advanced: Agent Handoffs* in
`skills/worktrunk/SKILL.md` for the case where a single Claude Code
session spawns multiple sub-Agents to work autonomously in their own
worktrees — no terminal multiplexer, no human in the other pane. The
existing handoffs section only covers tmux/Zellij user-facing handoffs,
so future Claude sessions had no guidance pointing them at the canonical
pattern, and `Agent { isolation: "worktree" }` was the obvious-looking
but wrong default.

## What it says

- **Canonical recipe**: pre-create each worktree from the parent with
`wt switch --create <branch> --no-cd --no-hooks`, then call `Agent`
*without* `isolation: "worktree"` and name the path in the prompt.
- **Footgun call-out**: don't use `Agent { isolation: "worktree" }` with
this plugin. Claude Code passes its internal agent ID as `name` to the
`WorktreeCreate` hook, so `wt` creates the worktree as
`worktrunk.agent-<id>` on a throwaway branch. If the sub-Agent then
creates a feature branch on top, you end up with non-canonical paths,
orphan branches, and post-start hooks fired against the wrong branch.

The literal API name appears in the warning so a future Claude
pattern-matches the situation when it's about to call that exact tool.

## Test plan

- [x] `cargo test --test integration test_docs_are_in_sync` — passes
(regenerated `docs/static/.well-known/agent-skills/index.json` digest)
- [x] `cargo run -- hook pre-merge --yes` — 3491 tests pass, lints clean

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:58:02 -07:00
Maximilian Roos c82236e034 refactor(config): rename path-traversal filters to dirname/basename (#2605)
\`name\` was too generic — likely to collide with future filters
operating on domain objects (a Branch, a worktree, a remote). Switch to
the POSIX/jinja-convention pair so both filters travel together: ansible
ships \`dirname\`/\`basename\`, shell users already know them, and
\`dirname\` is no more ambiguous than \`parent\` was.

Follow-up to #2592, which landed ~30 minutes ago — no users to break
yet.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-04 13:23:39 -07:00
Maximilian Roos 670cb2a7b0 feat(config): add parent and name path-traversal template filters (#2592)
Two new filters expose `Path::parent` and `Path::file_name` to
templates, enabling path traversal that previous filters couldn't
express. They unblock the bare-repo-in-hidden-directory layout
(`myproject/.git`), where `{{ repo }}` resolves to `.git`: users who
want a richer worktree-path naming scheme can now write `{{ repo_path |
parent | name }}` to recover `myproject`.

The interactive bare-repo prompt still suggests the simpler `{{
repo_path }}/../{{ branch | sanitize }}` template, since the bare layout
already nests worktrees inside a repo-specific wrapper directory — no
prefix needed for disambiguation. The filters are there for users with
different layout preferences (#1281 discussion).

Thanks to @seakayone for reporting #1279 and @Xilis for raising the
`parent_dir` question that prompted this approach.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-04 11:55:04 -07:00
Worktrunk Bot 1d97bb7a20 feat(config): add [remove] delete-branch config option (#2589)
## Summary

Adds a `[remove]` config section that lets users default `wt remove` to
keeping branches, requested in #2587. Setting `delete-branch = false` is
equivalent to passing `--no-delete-branch` every time; CLI flags still
override the config.

```toml
[remove]
delete-branch = false   # never delete branches when removing worktrees
```

Implementation mirrors the existing `[merge]` pattern: new
`RemoveConfig` section, a `[projects."<id>".remove]` override path, and
a tri-state CLI flag pair (`--delete-branch` / `--no-delete-branch`) so
users can override config either direction.

## Test plan

- [x] Unit tests for `RemoveConfig` parse/default/merge + project
override (`src/config/user/tests.rs`)
- [x] Integration test: `[remove] delete-branch = false` keeps the
branch on `wt remove`
- [x] Integration test: explicit `--delete-branch` overrides the config
- [x] All existing `wt remove` tests still pass (83 tests)
- [x] `cargo test --lib --bins` and `cargo clippy --tests
--all-features` clean
- [x] Help/docs snapshots regenerated (`test_docs_are_in_sync`)

Closes #2587.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:26:56 -07:00
Maximilian Roos 3593606283 refactor: route every short-SHA display through git's abbreviation logic (#2576)
Every site that abbreviated a commit SHA was either slicing `&sha[..7]`
or running its own ad-hoc `git rev-parse --short` call. 7-char prefixes
regularly collide in repos with many commits, and none of the slicing
sites honored `core.abbrev`. Cut over to a single canonical helper.

## Single helper, every display site

`Repository::short_sha(&str) -> Result<String>` wraps `git rev-parse
--short`. Routes display through git's own abbreviation logic so
`core.abbrev` is honored and prefixes auto-extend on collision. Used by:

- `step commit` / `step squash` success lines
- `step push --no-ff` `Merged to @ <hash>` line — the original bug.
Flagged on #2560 as a pre-existing third instance of the same pattern
fixed there in `commit.rs` and `step_commands.rs`.
- `{{ short_commit }}` template var in hook contexts
(`command_executor.rs`, `template_vars.rs`)
- post-remove hook context for the removed worktree
- safety-backup ref display (`create_safety_backup`)
- `(detached <sha>)` label in the orphan-check loop

All seven sites previously sliced `commit[..7]` or called their own
`rev-parse`. Now they route through one helper.

## Batched form for `wt list --format=json`

The JSON list path emits one `short_sha` per worktree row. Looping
`short_sha` would fork a subprocess per row, so the short SHA is folded
into the existing `commit_details_many` batch instead — `%h` is added to
the `git log --no-walk --format=...` call that already fetches timestamp
and subject. One subprocess for the whole list, same `core.abbrev`
behavior as every other site.

`CommitDetails` gains a `short_sha: String` field with the same
provenance as the timestamp and subject. The JSON schema is unchanged
(`commit.short_sha` was already a field) — only its length now varies by
`core.abbrev` instead of being hard-coded to 7.

## API change

`TemplateVars::with_active_commit(commit, short_commit)` now takes both
forms. Previously it sliced `commit.get(..7)` internally. The sole
caller (`worktree/finish.rs`) resolves the short form via
`Repository::short_sha` and passes both.

## Docs

`{{ short_commit }}` is no longer documented as "(7 chars)" —
`src/cli/mod.rs` and `src/config/project.rs` now describe `core.abbrev`
behavior. Doc-sync regenerated `docs/content/hook.md` and the skill
mirror.

## Tests

Full suite passes (3463/3463). `commit_details_many` tests updated for
the new tuple shape; `CommitDetails` fixtures in `layout.rs` get a
`short_sha` field; `template_vars` tests pass both forms explicitly. Two
integration tests previously asserting `short_commit` was exactly 7
chars (`post_start_commands.rs`, `user_hooks.rs`) still pass — fresh
test repos default to `core.abbrev = 7`.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 23:17:03 -07:00
Maximilian Roos 275eaead91 feat: add --format=json to seven step + hook commands (#2560)
Extends structured-output coverage beyond
`list`/`switch`/`remove`/`merge` to the remaining commands where JSON is
useful for scripting and Claude Code integration: `step rebase`, `step
push`, `step commit`, `step squash`, `step relocate`, `step
copy-ignored`, and `hook show`.

JSON shapes follow the existing pattern (additive on stdout; human prose
stays on stderr) and use stable snake_case `outcome` discriminators
where the result is one of several variants.

## JSON shapes

| Command | Payload |
|---|---|
| `step rebase` | `{target, outcome:
"rebased"\|"fast_forwarded"\|"up_to_date"}` |
| `step push` | `{target, outcome:
"fast_forwarded"\|"up_to_date"\|"merge_commit", commits, merge_sha?}` |
| `step commit` | `{commit, message, stage_mode}` (resolved mode, not
raw flag) |
| `step squash` | `{outcome:
"squashed"\|"no_commits_ahead"\|"already_single_commit"\|"no_net_changes",
commit?, message?, stage_mode?, target?}` |
| `step relocate` | `{dry_run, entries: [{branch, from, to}], skipped:
[{branch, reason}]}` |
| `step copy-ignored` | `{outcome, dry_run, from, to, entries: [{path,
kind}], files, bytes}` |
| `hook show` | `[{type, source, name, template, needs_approval,
expanded?}]` |

## Notable refactors

- `RebaseResult::Rebased` now carries `{target, fast_forward}`.
- `SquashResult::Squashed` now carries `{sha, message, stage_mode}`.
- `CommitOutcome` returned from `commit_staged_changes` and
`CommitOptions::commit` carries `{sha, message, stage_mode}` — the
*resolved* mode that was actually applied (CLI flag merged with config
defaults), not the raw `Option<StageMode>` from clap. Without this,
`--format=json` would emit `null` whenever the user didn't pass
`--stage`.
- `handle_push` / `handle_no_ff_merge` return `PushResult { target,
commit_count, outcome }` with outcome variants `FastForwarded` /
`UpToDate` / `MergeCommit { merge_sha }`. The merge-commit SHA was
previously discarded.
- `relocate::GatherResult` carries `template_error_branches` (was:
opaque count). Text mode already showed these in stderr; JSON now
surfaces them as `skipped` entries with `reason: "template_error"` so
automation can detect a broken `worktree-path` config rather than
reading `entries: []` as success.

## Behavioral guards

`--show-prompt` is rejected when combined with `--format=json` for `step
commit` / `step squash` — show-prompt emits raw LLM-prompt text that
would corrupt a JSON consumer's stdout.

## Reviewed by Codex

Two rounds. Round 1 caught the relocate template-error case (fixed in
this PR). Round 2 reported no remaining correctness issues.

## Tests

13 new integration tests covering the JSON shapes (parsed into
`serde_json::Value` and asserted against concrete keys). Full suite:
1604 / 1604 passing locally.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 17:24:10 -07:00
Maximilian Roos e5d0c138ea Add wt step {commit,squash} --dry-run, hide --show-prompt (#2557)
Adds `--dry-run` to `wt step commit` and `wt step squash`. It renders
the prompt, prints the shell invocation that would call the LLM, calls
the LLM and prints the generated message in three labeled sections
(PROMPT, COMMAND, MESSAGE), then exits without staging, running hooks,
or committing. For commit, `--stage` is honored against a temp index —
the previewed prompt matches what a real run would send the LLM, but the
user's real index is never touched. Output is routed through
`show_help_in_pager` so the prompt section (50+ lines) doesn't scroll
off; piping (`| grep`, `| jq`) still works because the helper
TTY-detects.

`--show-prompt` is hidden via clap (`hide = true`) but kept working —
it's still the right shape for piping the cheap rendered prompt to
another LLM (`wt step commit --show-prompt | llm -m gpt-5-nano`), and
`GitError::LlmCommandFailed` still suggests it as a reproduction
command.

A new "When to page output" section in the `writing-user-outputs` skill
documents the policy: page long human-oriented stdout (`--help`, `wt
config show`, `wt hook show`, `--dry-run`); don't page pipe-first data
or output already paged by a delegated tool (`git diff`).

Follow-ups during review: `render_llm_invocation` now uses the shell's
basename (no install-path leakage), with an insta filter normalizing
`bash.exe` → `sh` for cross-platform snapshot stability. Added a
`run_git_capture` helper around the new direct `Cmd::new("git")` calls
that bails on non-zero exit (was a non-blocking reviewer observation —
without it, a failing `git diff --staged` would silently feed an empty
diff to the LLM). `stage_to_temp_index` was refactored to take argv
directly, eliminating a dead `StageMode::None` arm. Added unit tests for
`render_llm_invocation` and integration tests for `--dry-run
--stage=tracked` and `--dry-run` without LLM configured.

`codecov/patch` reports 95.29% with 9 misses. The remaining gap is split
between error-path fault-injection (`stage_to_temp_index` git-add
failure, `show_help_in_pager` spawn failure) and a codecov attribution
mismatch — local `cargo llvm-cov` shows `render_llm_invocation` as
covered by the new unit tests, but codecov doesn't credit them. Merged
with explicit override.

Test plan:
- [x] `cargo test --test integration -- merge::test_step_commit_dry_run
merge::test_step_squash_dry_run merge::test_step_commit_show_prompt
merge::test_step_squash_show_prompt`
- [x] `cargo run -- hook pre-merge --yes` (3438 tests pass, clippy
clean)
- [x] End-to-end smoke test in `/tmp/wt-dry-test`: untracked file
appears in dry-run prompt, real index stays untouched
- [x] `--dry-run` and `--show-prompt` are mutually exclusive (clap
`conflicts_with`)
- [x] CI green on Linux/macOS/Windows after the cross-platform shell
filter fix

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 13:51:19 -07:00
Maximilian Roos 8d5fff83c6 docs(homepage): replace "Scaling agents becomes trivial" intro line (#2553)
The line was a generic LLM-flavored flourish ("scaling," "becomes
trivial") that also overclaimed: it sat above the core-commands GIF,
whose own caption honestly says "Listing worktrees, switching, cleaning
up." The parallel-agents demo lives further down the page.

Drop the slop sentence above the basics demo, and move the zoom-out
claim down to the second demo's lead-in, where the GIF actually
substantiates it: `"Multiple parallel agents, same simple commands:"` —
ties back to the "as easy as branches" framing in the intro.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 10:57:52 -07:00
Maximilian Roos 4b6316ac29 Release v0.47.0 (#2552)
Cuts v0.47.0. Highlights:

- **Integration detection fix** across `wt list`, `wt remove`, `wt
merge`, `wt step prune` for the local/upstream-diverged case (#2507,
#2513, #2515).
- **Faster shell-command dispatch** (~10ms per invocation, 1.49× on
trivial aliases) via event-driven signal forwarding (#2537, #2538), plus
parallel config load (#2543) and merged cold-start prewarm rev-parse
(#2541).
- **`wt switch <number>` suggests `pr:N` / `mr:N`** instead of just
`--create` (#2516).
- **Library API breaking changes**: `RefSnapshot` cutover (#2528, #2530)
and the `for-each` direct-exec change (#2465). Full list in
`CHANGELOG.md`.

See `CHANGELOG.md` for the complete entry.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 10:49:00 -07:00
Maximilian Roos b12ac078b3 docs(extending): add stdin row to hooks-vs-aliases comparison (#2529)
The comparison table in `extending.md` covered invocation, positional
handling, approval flags, source filters, and template-context extras —
but never said anything about stdin. Hooks have always received the
template context as JSON on stdin (documented in `hook.md`), and since
#2380 aliases inherit the parent's stdin so pipes pass through and
interactive TUIs (`wt switch`) keep the tty. Adding a row makes the
contract discoverable from the aliases page rather than only from the
CHANGELOG.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 10:55:33 -07:00
Maximilian Roos 845baeb976 refactor(git): RefSnapshot — close ambient ref-keyed cache staleness class (#2528)
## Summary

Replaces the ambient ref-keyed caches in `RepoCache` with an explicit,
point-in-time `RefSnapshot` value threaded through read paths. This
closes the bug class behind PR #2507 (post-`update-ref` integration
check seeing pre-write SHAs) — and several latent variants — by making
the caches that could go stale unrepresentable.

## Why

`RepoCache` cached ref-name → SHA in `commit_shas: DashMap<String,
String>`, plus composite caches keyed off it (`integration_reasons`,
`effective_integration_targets`, `ahead_behind`, `integration_target`
OnceCell, `tree_shas`, `resolved_refs`). When wt itself moved a ref
mid-command — most prominently `wt merge` running `git update-ref
refs/heads/<target>` to advance the local target — those caches went
stale, and any downstream read returned pre-write SHAs.

PR #2507 patched one symptom by adding `ref_is_ancestor` (a cache-bypass
helper) at the integration-check call site. That works locally; every
other ref-name read after a write was a latent bug.

The structural fix: there is no longer an ambient ref-name → SHA cache.
Callers explicitly capture a `RefSnapshot` at known points and thread it
through the read paths. After ref-mutating operations, callers capture a
fresh snapshot — the discipline lives at the small set of write
boundaries (currently one site, `finish_after_merge`), not at every
ref-name accessor.

Background analysis: `docs/dev/cache-staleness.md` (commit ba8b239)
walks through six options and the trade-offs.

## What changed

- **New `src/git/repository/ref_snapshot.rs`** — `RefSnapshot` value
type. One `for-each-ref` over local + remote refs, optional ahead/behind
batch. Cloned by value, no `OnceCell`/`Arc`. ~330 lines including unit
tests (the freshness contract test mutates a ref and re-captures).
- **Two-tier API** — `_by_sha` siblings of cached methods
(`is_ancestor_by_sha`, `merge_base_by_sha`, `branch_diff_stats_by_sha`,
`ahead_behind_by_sha`, `has_added_changes_by_sha`, `trees_match_by_sha`,
`merge_integration_probe_by_sha`, …). The persistent on-disk `sha_cache`
(already SHA-keyed) sits behind these.
- **Snapshot-taking integration path** — `compute_integration_lazy`,
`integration_reason`, `compute_integration_reason_uncached`,
`integration_targets`, `delete_branch_if_safe`,
`remove_worktree_with_cleanup` all take `&RefSnapshot`.
- **Post-write fresh-snapshot pattern** — `wt merge`'s
`finish_after_merge` captures a *fresh* snapshot AFTER
`handle_no_ff_merge`/`handle_push` runs `update-ref`. This is the
structural fix for PR #2507. The `ref_is_ancestor` workaround is
deleted.
- **`wt list` migration** — pre-skeleton phase captures one snapshot;
`TaskContext` carries it; tasks call `_by_sha` siblings via the
snapshot. No regression intended (one extra `for-each-ref` upfront, lose
per-task `rev-parse` fallbacks).
- **Cache deletions** — `commit_shas`, `tree_shas`,
`effective_integration_targets`, `integration_reasons`, `ahead_behind`,
`integration_target` OnceCell, `resolved_refs` all gone from
`RepoCache`. `LocalBranchInventory`'s priming side-effects (writes to
`commit_shas`/`resolved_refs`) removed.
- **Uncached primitives** — `rev_parse_commit`, `rev_parse_tree`,
`resolve_preferring_branch` no longer cache; consumers that need
SHA-stable lookups thread a `RefSnapshot`.
- **Persistent caches retained** — on-disk `sha_cache::*` and the
in-memory `merge_base` / `branch_diff_stats` caches keep their SHA-keyed
contract.

## Snapshot test deltas

Three `list_*_with_nonexistent_default_branch.snap` snapshots no longer
show the same-commit `_` symbol when the configured default branch is
missing — that was a false positive (no ref to compare against).
`list_warns_when_commit_details_batch_fails.snap` now embeds the
resolved SHA in the error rather than the literal `"main"` ref name
(strict improvement — the SHA is what was passed to git).

## Regression coverage

- PR #2507's regression test
(`test_remove_merged_locally_when_upstream_diverged`) passes without
`ref_is_ancestor`.
- PR #2513's
`test_list_integrated_when_merged_locally_with_upstream_diverged`
passes.
- New unit test: `RefSnapshot` capture-mutate-recapture confirms two
captures observe distinct SHAs after `update-ref`.

## Out of scope (deferred)

Both flagged by `/reviewing-code` and explicitly deferred:

- `prepare_worktree_removal` re-captures a snapshot per candidate inside
`step_prune` — N+1 `for-each-ref` calls. Threading an optional
`&RefSnapshot` would make `step_prune` reuse its entry snapshot. Affects
display consistency in unusual hook setups (a `post-remove` hook that
runs `git fetch` between candidates can desync the displayed integration
target from the safety decision); not a safety bug.
- `head_shas` cache retained. Different staleness model (per-worktree,
mutated by `commit`/`reset` rather than `update-ref`); design doc lists
it as a remaining vector. Out of scope for this PR's integration-target
focus.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (3413 tests pass before the
merge of `origin/main`)
- [x] 1071 lib + 607 bin + 1588 integration tests pass after merging
`origin/main`
- [x] `cargo check --all-targets` clean
- [ ] CI green

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:59:41 -07:00
Worktrunk Bot f418729e21 fix(step/copy-ignored): drop .pi/ from built-in excludes (#2527) 2026-05-02 06:13:49 -07:00
Worktrunk Bot 6eaf349711 refactor(for-each): exec argv directly, drop implicit shell (#2465)
## Problem

`wt step for-each` rebuilt the post-`--` command with `args.join(" ")`
before passing it to `sh -c`. That dropped the user's quoting and argv
boundaries, so anything with spaces, `;`, or shell metacharacters inside
an argv element broke. The reproduction from the issue:

```console
$ wt step for-each -- python3 -c 'import sys; print(sys.argv[1:])' 'a b'
sh: 1: Syntax error: word unexpected (expecting ")")
```

## Solution

Drop the implicit shell. The post-`--` argv is the program and its
arguments, exec'd directly via `Cmd::new(program).args(rest)`. Templates
expand into argv elements with `shell_escape=false`. Users wanting shell
features (pipes, redirects, `$VAR`, globs) write `sh -c '<snippet>'`
explicitly — the same pattern as `xargs`, `find -exec`, `kubectl exec
--`, and `docker run`.

This started as the smaller fix that landed on the previous commit
(shell-escape each argv element before joining, with an arity branch —
one arg meant "shell snippet," many args meant "argv to escape and
re-join"). On review the arity asymmetry felt wrong; the design
discussion concluded that direct exec is one rule with no quoting
recovery pipeline, and the #2461 bug becomes structurally impossible.
`for-each` is still tagged `[experimental]`, so the small breakage of
the documented snippet form is acceptable.

After the fix:

```console
$ wt step for-each -- python3 -c 'import sys; print(sys.argv[1:])' 'a b'
['a b']

$ wt step for-each -- sh -c 'git status | wc -l'
[per-worktree counts...]
```

### What changes for users

| Before | After |
|---|---|
| `wt step for-each -- 'git status \| wc -l'` | `wt step for-each -- sh
-c 'git status \| wc -l'` |
| `wt step for-each -- 'echo Branch: {{ branch }}'` | `wt step for-each
-- echo 'Branch: {{ branch }}'` (or wrap in `sh -c`) |
| `wt step for-each -- python3 -c '...' 'a b'` ✗ broken | `wt step
for-each -- python3 -c '...' 'a b'` ✓ works |

The `for-each` help/docs are rewritten around the simpler model (one
rule, two example blocks).

## Testing

- `test_for_each_preserves_argv_quoting` covers the exact reproduction
from the issue and is now exercising a load-bearing property of the
design rather than a join bug.
- `test_for_each_aborts_on_signal_exit` rewritten to use `sh -c
'<snippet>'` for the shell features it needs.
- `test_for_each_json_spawn_failure` simplified — direct exec means a
missing program is enough; the PATH/symlink dance for forcing sh-spawn
failure is no longer needed.
- `cargo test --test integration for_each` — 16 passed.
- `cargo run -- hook pre-merge --yes` — 3403 passed, 0 skipped, all
lints green.

---
Closes #2461 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-30 12:37:34 -07:00
Maximilian Roos 093f6b3fcf Release v0.46.1 2026-04-30 10:21:28 -07:00
Worktrunk Bot 735756a46a fix(docs): use __WT_QUOT__ placeholder in tips-patterns terminal blocks (#2496) 2026-04-30 01:25:59 -07:00
Maximilian Roos 01a8258b6e Release v0.46.0 2026-04-29 16:40:05 -07:00
Worktrunk Bot 5dae8d8e92 feat(config)!: cap sanitize_db output at 48 chars (#2467)
## Summary

- Lowers `sanitize_db`'s total output cap from 63 (PostgreSQL's
identifier limit) to 48, leaving headroom for users composing the output
into longer paths or identifiers — e.g., Unix socket paths capped at 107
bytes (#2397).
- The 3-char hash suffix is unchanged (still derived from the original
input), so collision avoidance is preserved at the new budget; only the
truncated base shrinks.
- **Breaking change** for branches whose current `sanitize_db` output
exceeds 48 chars: the truncated base shifts, so the final identifier
changes. Most branch names are well under 48 chars and pass through
unchanged. Users who relied on the previous output as a stable database
identifier should be aware.

Companion to #2453 (added the `hash` filter so users can compose their
own truncate-with-collision-avoidance recipes when 48 still isn't tight
enough).

Refs #2397.

## Test plan

- [x] `cargo test --lib --bins -- sanitize_db` — 5 unit tests pass
(including updated `test_sanitize_db_truncation`)
- [x] `cargo test --test integration -- sanitize_db` — 3 integration
tests pass (including updated `test_doc_sanitize_db_truncation`)
- [x] `cargo test --test integration test_docs_are_in_sync` — passes
(docs/skills auto-synced)
- [x] `cargo insta test --accept --test integration -- "test_help"` —
help snapshots regenerated; no surprise diffs

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-29 12:43:49 -07:00
Worktrunk Bot 719a102409 feat(config): add hash template filter (#2453) 2026-04-28 23:30:55 -07:00