Commit Graph

4850 Commits

Author SHA1 Message Date
Worktrunk Bot 901b79ab52 ci: bump pinned worktrunk to 0.69.2 (#3612)
Weekly CI pin bump. Only `worktrunk` drifted since last week — every
other `baptiste0928/cargo-install` pin (cargo-msrv `0.19.3`,
cargo-llvm-cov `0.8.7`, cargo-insta `1.48.0`, cargo-nextest `0.9.140`,
cargo-udeps `0.1.61`, lychee `0.24.2`), plus `setup-nu` `0.114.1` and
`zola@0.22.1`, is already at the latest upstream release.

- **worktrunk** `=0.68.0` → `=0.69.2` (3 sites: `ci.yaml` ×2,
`nightly.yaml`)

Compatibility: worktrunk `0.69.2` declares `rust-version = 1.96`,
matching the pinned toolchain (`rust-toolchain.toml` channel `1.96.0`),
so it builds under `baptiste0928/cargo-install`.

MSRV/toolchain needed no bump this week: current stable is `1.97.1`, so
latest−1 is `1.96`, which is already what `Cargo.toml`,
`tests/helpers/wt-perf/Cargo.toml`, and `rust-toolchain.toml` pin.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-26 04:51:25 -07:00
Worktrunk Bot e9b0bca15c docs(log-files): init() creates three sinks, not two (#3610) 2026-07-26 01:56:29 -07:00
Worktrunk Bot 4cf3006041 fix(taskfile): drop codex from bench list without leaving an empty element (#3611) 2026-07-26 01:56:11 -07:00
Maximilian Roos 32f380a27b refactor(switch): forward the forge CLI's own error text (#3605)
Follow-up to #3600, independent of it (no shared files).

GitHub and GitLab rewrote forge API failures into messages of our own,
discarding what the CLI said. GitLab picked its three by prose —
`error_text.starts_with("404")` — the pattern #3595/#3597 removed
elsewhere.

## Before / after

A GitLab MR that doesn't exist:

```
- ✗ MR !9999 not found
+ ✗ glab api failed for MR !9999
+ ▎ glab: 404 Not found (HTTP 404)
```

A GitHub PR that doesn't exist — the one message kept, now with gh's
verdict under it rather than instead of it:

```
  ✗ PR #999999 not found on max-sixty/worktrunk (gh default). Check that `gh repo set-default` points to the correct repository.
+ ▎ gh: Not Found (HTTP 404)
```

## The rule, now written down

`src/git/remote_ref/mod.rs` gets an **Error Messages** section: forward
the CLI's own line, and write our own only where the CLI *structurally
cannot* report the condition. Two cases qualify — GitHub's 404 (it
answers about an owner/repo *we* chose, from `gh repo set-default` or a
remote; gh can't know that) and Azure's missing `azure-devops` extension
(a precondition `az extension list` answers, which `az` never names).
Reading more nicely than the CLI doesn't qualify.

`cli_api_error` and `cli_api_error_details` gained docstrings for the
mechanism: our context as the headline, the CLI's words in the gutter,
so a provider with something to add passes it as `message` rather than
bailing.

## Evidence

Measured, not assumed — both CLIs render an API failure the same way:

| | stdout | stderr |
|---|---|---|
| `gh api …/pulls/999999` | `{"message":"Not Found",…,"status":"404"}` |
`gh: Not Found (HTTP 404)` |
| `glab api …/merge_requests/…` | `{"message":"401 Unauthorized"}` |
`glab: 401 Unauthorized (HTTP 401)` |

`cli_api_error_details` prefers stderr, so the gutter gets the human
line, not the JSON. That also surfaced a test bug: **the glab mocks
never set stderr**, so they'd have enshrined output real glab doesn't
produce. Fixed to match the probe.

`gh` puts the status in a structured field, so its 404 arm keys on
`status`. `glab` puts it only inside the message text — nothing to key
on, which is why prose-matching was the only way to keep those arms.

## What this gives up

A bad or expired token no longer gets "run gh auth login" / "run glab
auth login"; it gets `gh: Bad credentials (HTTP 401)`. The far more
common case — no auth configured at all — is unaffected: gh prints its
own "please run: gh auth login" and always did fall through to
forwarding. Happy to restore the GitHub 401 arm (it's structurally
keyable); GitLab's can't come back without prose-matching.

## Testing

`cargo run -- hook pre-merge --yes` → 4572 passed. Six snapshots
reviewed individually before accepting (five updated, one new). Both
paths also driven through the **real** `gh` binary (a live 404 and a
live 401), not only mocks. Clippy clean with `--features
shell-integration-tests`.

Coverage: the GitHub 404 arm and the forwarding fallthrough each have a
test, on both forges. `codecov/patch` also flagged one line —
re-indenting the 404 block pulled a pre-existing gap into the patch, and
it turned out to be real: the `gh repo set-default` half of the hint,
the fork workflow the message exists for, had never been tested.
`test_switch_pr_not_found_gh_default` closes it.

> _This was written by Claude Code on behalf of Maximilian Roos_
2026-07-25 15:02:07 -07:00
Maximilian Roos 9645e3e132 fix(shell): reclaim the legacy wrapper paths instead of inspecting them (#3602)
`wt config shell install` decided whether the file at a legacy location
was worktrunk's by reading it — a substring test for the wrapper header,
falling back to "every code line looks like an integration line".
Anything that didn't match was left in place. Fish sources `conf.d` at
startup, so whatever `conf.d/{cmd}.fish` defines is already loaded by
the time fish would autoload the `functions/{cmd}.fish` the install just
wrote — the stale definition wins and the new wrapper may never load.

Ownership is now the path. `conf.d/{cmd}.fish` and the stranded nushell
`{cmd}.nu` candidates are paths worktrunk itself computes for the
command name being installed, so install takes them back whole without
reading them. Only that exact filename is touched: a neighbour under
another name is not worktrunk's, and each removal is still reported.

`wt config shell uninstall` still reads the header, because it takes no
`--cmd` and so genuinely doesn't know the name — it lists the
shell-owned directories and has to tell our `{cmd}.fish` from the user's
own files beside it. That is the one place ownership can't come from the
path, and it prompts and previews every file before removing it, which
install does neither of. `is_worktrunk_managed_content`'s docstring now
says so; `is_worktrunk_managed_nushell` existed only to gate the
install-time deletion and is gone.

Audited the rest of the surface for the same principle: the OpenCode
plugin (`~/.config/opencode/plugins/worktrunk.ts`) is already removed by
path with no inspection, the Claude plugin delegates to `claude plugin
uninstall`, Gemini installs through `gemini extensions install` and
leaves nothing of ours on disk, and fish is the only shell with a
separate completion file — which uninstall already covers. No other
gaps.

## Data safety

This deliberately widens what install deletes, so it's worth naming: two
tests asserted the old conservatism and now assert the new boundary —
`test_configure_shell_fish_reclaims_conf_d_path` (was
`..._preserves_user_conf_d_file`, from #3589) and
`test_nushell_install_reclaims_only_the_command_name` (was
`..._keeps_unmanaged_legacy_file`, from #2992). Both put a neighbour
file in the same directory to pin that only `{cmd}.{ext}` is taken. The
FAQ's "What can Worktrunk delete?" inventory is updated to describe
path-based ownership rather than the old content-marker rule.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:10:00 -07:00
Worktrunk Bot bcd1ffdfde feat(worktree): warn when a branch is checked out in multiple worktrees (#3480)
## Problem

`git worktree add --force <path> <branch>` bypasses git's "already used
by worktree" guard, so the same branch can be live in two worktrees at
once — breaking worktrunk's branch ⇔ worktree bijection. worktrunk never
creates that state itself, but once it exists
[`worktree_for_branch`](https://github.com/max-sixty/worktrunk/blob/cfd7fe469/src/git/repository/worktrees.rs#L64)
silently resolved to whichever worktree git listed first (roughly
creation order), and that choice flowed into every resolution path — `wt
switch`, `wt push`, `wt step diff`, `wt context`, the picker — with no
warning, error, or way to name the shadowed worktree.

This implements the **detect-and-warn** option from #3392: make the
invisible choice visible without changing resolution semantics.

## Solution

- `worktree_for_branch` now collects **all** worktrees on the branch via
a pure `worktree_paths_for_branch` helper; when more than one exists it
warns once (per branch, deduplicated per process) and still resolves to
the first — semantics are unchanged.
- The warning names every path and points at a concrete fix:

```
▲ Branch feature is checked out in 2 worktrees; wt uses the first:
   ┃ ~/repo.feature
   ┃ ~/repo.feature-dup
↳ To drop a duplicate, run git worktree remove ~/repo.feature-dup
```

Detection is separated from emission so the pure list logic is
unit-testable and the ambiguity check stays cheap — `list_worktrees()`
already holds the full list.

## Testing

- Unit test `test_worktree_paths_for_branch_detects_duplicates` — the
pure helper returns both paths in git's listing order for a duplicated
branch, one path for a unique branch, and none for an absent one.
- Integration test `test_step_diff_duplicate_branch_warns` — creates the
duplicate with `git worktree add --force`, runs `wt step diff
--branch=feature`, and snapshots the rendered warning.

The warning only fires when a duplicate exists (which no prior test sets
up), so existing snapshots are unaffected.

---
Closes #3392 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-25 13:47:19 -07:00
Maximilian Roos 8865f20ab8 fix(git): bound the default-branch remote query, and make the bound bound (#3603)
Follow-up to #3596, which walled the test suite off from the network.
That PR left the same unbounded call live for real users:
`Repository::default_branch()` is the one detection helper allowed to
fall through to `git ls-remote`, and nothing limited how long it could
take *not* to answer — an unanswered SYN costs ~127 s per address on
Linux (`tcp_syn_retries=6`) and git tries each of a host's addresses in
turn, so a remote behind a dropped VPN or a dead host stalled `wt list
--full` and `wt switch` for minutes.

## `Cmd::timeout` didn't bound wall-clock

Adding a bound alone would have been decorative, which is the substance
of this PR. `run_with_timeout_impl` killed only the direct child, and a
grandchild inherits the child's stderr pipe — so a surviving one held
the write end open and `read_to_end` blocked until it exited. Measured
on a 3 s timeout over an `ls-remote` whose upload-pack sleeps 120 s:

| | elapsed |
|---|---|
| before | 120.03 s |
| after | 3.20 s |

`git-remote-https` sitting in `connect()` is exactly that shape: it
doesn't notice git died. So a timed child spawns into its own process
group and expiry tears down the group — `killpg` with TERM → KILL
escalation on Unix, `taskkill /T /F` on Windows, matching `wt step
tether`. Every existing `.timeout()` caller (the fsmonitor stop/lsof
probes, `reap.rs`, the shell probe) was latently unbounded the same way
and is fixed with it.

The isolation costs the kernel's tty broadcast: a Ctrl-C no longer
reaches a timed child, so the user waits out the remaining bound. That's
seconds, against an orphan holding a pipe for as long as its own
operation takes, once per spawn. Recorded at `run_with_timeout_impl` and
in CLAUDE.md's Signal Handling section, whose "only the current child
does" claim was no longer true.

## The cache is the other half

A timed-out query takes the local-inference fallback but is *not*
persisted to `worktrunk.default-branch`. That cache is what stops later
calls from re-detecting, so a guess made while the network was down
would otherwise become the repo's permanent answer. `detect_from_remote`
now returns a `RemoteDetection` enum so the cacheability decision is
explicit and exhaustive rather than an `Option` that loses the
distinction.

Only a timeout separates cleanly, via `ErrorKind::TimedOut`. `ls-remote`
exits 128 whether the network is down or the remote simply has no HEAD,
so telling those apart would mean reading git's error text — and
re-querying on every command is the cost the cache exists to avoid.
Those stay cached, as before.

## Reviewing

- `src/shell_exec.rs` — the process-group teardown; its docstring
carries the rationale
- `src/git/repository/config.rs` — `RemoteDetection`, the 10 s bound,
and the no-persist path
- `src/git/repository/mod.rs` — `run_command` now delegates to a
`run_command_bounded` that takes the bound

Also routes the four remaining hand-rolled git test envs
(`src/git/remove.rs`, `src/git/repository/tests.rs`,
`tests/integration_tests/bare_repository.rs`) through
`configure_git_env`, so they carry #3596's `GIT_ALLOW_PROTOCOL` deny
rather than re-deriving a subset of it. All four run local-only git
commands today, so this is completeness, not a live hole — and a full
suite run under `GIT_TRACE` confirms zero `git-remote-*`
transport-helper spawns across 4374 tests.

## Testing

Three tests, none of which touch the network: the grandchild case via
`sh -c 'sleep 30; :'` (which stops the shell `exec`ing sleep, so there
really is a grandchild), the end-to-end no-persist behavior via
`remote.origin.uploadpack` pointed at a `sleep`, and the fail-fast
unresolvable remote, which had no coverage before. The second waits out
the real 10 s bound; both hanging-remote tests are `#[cfg(unix)]` since
the vehicle needs a POSIX `sleep`, so `test (windows)` doesn't cover the
no-persist path.

> _This was written by Claude Code on behalf of max_
2026-07-25 13:47:09 -07:00
Worktrunk Bot 203603909d test(picker): release --prs loading marker on parent exit, not a timer (#3532)
## Problem

`test_switch_picker_prs_shows_loading_marker` intermittently times out
on `test (windows)` — it flaked on #3531 (a docs-only change), which is
what prompted this fix.

The test asserts a *transient* frame: while a mocked, delayed `gh pr
list` is in flight, the picker header shows `↳ Loading open PRs…` and
the `#42` row has not yet streamed in. The mock held that loading state
for a fixed time (originally 3s). On a loaded Windows runner, the PTY
boot sequence — `boot_picker_pty`'s skim-ready wait plus its initial
`wait_for_stable`, together with the async worktree-column churn (`·`
placeholders resolving) — can consume that whole window. By the time the
stabilization helper first polls for `Loading open PRs`, the mocked
fetch has already returned and the rows have streamed in, so the marker
is gone and the wait times out at `STABILIZE_TIMEOUT` (30s).

## Fix

Per review feedback
([comment](https://github.com/max-sixty/worktrunk/pull/3532#issuecomment-5039146240)),
tie the marker's lifetime to the picker's, with **no timing number at
all** — the file's "poll, don't pick a time" rule applied to the
producer side.

`mock-stub` gains a `hold_until_parent_exit` response mode: the mocked
`gh pr list` holds until `wt` exits instead of sleeping a fixed
`delay_ms`. Detection needs no platform primitive (`getppid` /
`OpenProcess`):

- While `wt` lives, its `--prs` fetch thread runs the mock with a piped
stdout and blocks reading that pipe to EOF, so as long as the mock
neither writes a full response nor exits, the loading marker stays.
- When the picker aborts, `wt` detaches the fetch thread
(`drop(prs_handle)`) and exits, which closes the read end of the mock's
stdout pipe. The mock's next write then fails with `BrokenPipe` (Rust
ignores `SIGPIPE`, so the broken write surfaces as an `Err` rather than
killing the process), and the mock exits.

Polling for that write error is a **causal** parent-death signal —
release is tied to `wt` exiting, not to a timer — and it behaves
identically on Unix and Windows, so there's no platform-specific FFI to
get right. The one-byte probes written into stdout are never observed
(this mode's contract is that the parent aborts without parsing the
response, and the fetch thread drains the pipe until process exit). A
60s poll cap bounds a stuck orphan if the pipe somehow never breaks.

This removes the race the earlier 30s bump only widened: the marker is
present for the entire window the poll could observe it, on any boot
latency, and no orphaned sleeper is left behind — the mock exits the
moment the picker does.

## Testing

`cargo test --test integration --features shell-integration-tests
test_switch_picker_prs_shows_loading_marker` passes in ~1.3s, confirming
the capture-and-abort path stays fast and the mock releases promptly on
`wt` exit. The sibling `--prs` tests
(`test_switch_picker_prs_{github,gitlab}_list`,
`..._rows_survive_alt_x_removal`) still pass through the refactored
`mock_forge_env` helper. Reasoning is documented inline at the mock-stub
call site and in `wait_for_parent_exit`.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-25 13:42:06 -07:00
Maximilian Roos b843051906 docs(faq): scope the worktree-lock advice to removal (#3601)
The FAQ recommended `git worktree lock` "for worktrees containing
precious ignored data (databases, caches, large assets)". The lock does
not do that — it blocks removal and nothing else. Someone who locked a
worktree to keep a local database safe has protection against `wt remove
--force`, and none at all against the thing that actually rewrites files
there.

An ignored file in a locked worktree is still replaced when commits
arriving via `wt merge` or `wt step push` track a file at the same path.
Reproduced against this FAQ's own example: a locked worktree holding
`db.sqlite` had it overwritten by the branch's tracked file, exit 0, no
warning.

That behavior is git's, not worktrunk's. A plain `git merge` in that
worktree does the same, and draws the line in the same place — it
refuses when the colliding file is untracked, and overwrites when it's
ignored:

```
ignored locally   → Fast-forward, db.sqlite | 1 +      (content: TRACKED-FROM-BRANCH)
untracked locally → error: The following untracked working tree files
                    would be overwritten by merge  (content: REAL-LOCAL-DATABASE)
```

So the fix is one sentence: the opener now says what the lock buys
(removal protection) instead of implying it guards ignored data. Nothing
in the paragraph below it changes — `wt remove --force` and `git
worktree remove --force` do both still refuse on a locked worktree,
checked against a scratch repo while editing.

> _This was written by Claude Code on behalf of Maximilian Roos_

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-25 13:15:47 -07:00
Maximilian Roos bcf6a287c1 refactor(error): give the unmerged-index phrase one home (#3599)
`{count} {paths} with unresolved conflicts` was built twice,
pluralization and all — once in `GitError::UnmergedPaths`
(`src/git/error.rs`), once in the warning `wt step relocate` prints when
it skips a worktree it can't commit (`src/commands/relocate.rs`).

The two can't share an error type: relocate warns and carries on, since
an unresolved conflict blocks one worktree out of the many it walks,
while `UnmergedPaths` refuses outright. So only the phrase moves, into
`format_unresolved_conflicts`.

Output is unchanged — the four snapshots covering both sites are
untouched and pass.

> _This was written by Claude Code on behalf of Maximilian_
2026-07-25 13:04:37 -07:00
Worktrunk Bot 8fd289ac48 test(alignment): drop dead no-op cross-row alignment check (#3575)
Nightly survey finding in
`tests/integration_tests/column_alignment_verification.rs`.

The block commented "Additional check: verify that ALL rows have the
same column start positions" computed a `matching_boundary` for each
header boundary and then did nothing with it — the only branch was `if
matching_boundary.is_none() { continue; }`, and the match case had no
body. It never asserted, recorded an error, or affected the test result,
so a genuine cross-row misalignment would have passed it silently. The
comment implied a verification that wasn't happening, which is worse
than no check — it reads as coverage that doesn't exist.

Removed the dead block along with the now-unused `all_row_boundaries`
accumulator and its per-row `push`. The remaining per-row check (each
column measured against the header position, lines 205–260) still runs;
since every row is verified against the same header, cross-row
consistency is already covered transitively — the deleted block was
redundant even had it worked.

No behavior change to the assertions: the five
`column_alignment_verification` tests plus the other alignment suites
still pass (`cargo test --test integration column_alignment` → 10
passed).

No new regression test — this removes dead test code with no
production-behavior change to guard; the existing alignment tests remain
the coverage.

_Note, not changed here:_ the per-column check for non-`Path` columns
(lines ~240–258) only `println!`s a warning rather than pushing to
`errors`, so today only a `Path`-column misalignment can actually fail
this test. That looks deliberate (Path was the historical alignment
bug), so I left it alone rather than tightening it and risking false
failures on intentionally-sparse columns — flagging it in case a
maintainer wants it made assertive.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-25 13:01:33 -07:00
Maximilian Roos 88319b8bf5 docs(changelog): document 0.69.1 drift fixes (#3567, #3579, #3560) (#3583)
Documents three user-facing fixes that landed on `main` after the
v0.69.1 release branch was cut. They shipped in the v0.69.1 binary (the
release squash-merged onto the current `main` tip) but weren't yet in
the `## 0.69.1` changelog section, so this adds them under 0.69.1 where
they belong — no version change.

- **#3567** — `wt -C` honoured by `wt list statusline`
- **#3579** — `wt step squash`/`wt step commit` refuse to commit
unresolved conflicts; unified `wt list` operation gutter symbol
- **#3560** — `wt switch` picker cancels its orphaned background preview
processes on exit

Each was independently reviewed for the data-loss surface as part of the
release (all clean; #3579 is safety-positive).

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 13:00:55 -07:00
Maximilian Roos ae363c2b4c test: stop the suite reaching the network (#3596)
`ci_status::test_list_full_with_configured_platform_github` hit
nextest's 180s timeout in an advisory CI job while the same 2586-test
run passed everything else. Its unloaded runtime is 2.2s, and a `-vv`
trace of the same fixture shows exactly one non-local subprocess: `git
ls-remote --symref origin HEAD` against
`https://bitbucket.org/test-owner/test-repo.git`, the URL the test sets
on itself. `Repository::default_branch()` falls through to the wire when
neither the worktrunk cache nor `origin/HEAD` resolves, which is
sanctioned behavior, but nothing bounds it, so the test blocks for as
long as the connect takes. Reproduced by pointing that remote at an
unroutable address: 11s on macOS, and on Linux an unanswered SYN costs
~127s per address (`tcp_syn_retries=6`), with `bitbucket.org` publishing
six.

It isn't one test. Setting `GIT_TRACE` across the harness shows a single
full-suite run spawning 180 `git-remote-https` processes: 63 to
github.com, 42 to gitlab.com, 27 to dev.azure.com, 3 to bitbucket.org,
and the rest to names that don't resolve. Every one is unbounded, on
three OSes, on every PR.

So the fix goes in the harness rather than in the one test.
`GIT_ALLOW_PROTOCOL=file` joins `STATIC_TEST_ENV_VARS`, which covers
every `wt` subprocess and every PTY env builder including the
hand-rolled ones, and the two git-only builders set it as well for
direct `git_command()` spawns that don't consult that table. Local paths
and `file://` keep working; anything else fails in milliseconds with
`transport 'https' not allowed`, and default-branch detection falls back
to local inference, so output is unchanged. `GIT_TERMINAL_PROMPT` was
never enough on its own: it only suppresses the credential prompt that
the host's 401 triggers, by which point the request has already gone
out.

The rust-lang/rust benchmark fixture clone is the one git call that
*should* reach upstream. It opts back in through
`allow_network_transports`, which lives next to the constant so the deny
and the opt-out can't drift apart.

`switch_pr_self_hosted_tea_authed` recorded the resolver's `Could not
resolve host` in its snapshot, which is the same defect class (asserted
output depending on DNS). It now records the transport refusal.

Also folded in: `build_worktree_config_bare_layout` in
`src/git/repository/tests.rs` carried a nine-line hand-rolled copy of
`configure_git_env`, so it now calls it.

Not addressed here: nothing bounds that `ls-remote` for real users
either, so an unreachable remote blocks `wt list --full` and `wt switch`
for minutes on the first call per repo. That needs a decision about
whether a timed-out detection should write the
`worktrunk.default-branch` cache.

> _This was written by Claude Code on behalf of max_
2026-07-25 12:15:42 -07:00
Maximilian Roos 5f1ce2341f refactor(commit): give the staging gate one name and one StageMode mapping (#3598)
Three simplifications to the staging gate #3588 introduced, all
deletions.

## `stage` names the commit instead of taking a caller's name

`WorkingTree::stage` took an `action` string purely so its refusal could
name the command the user typed. But staging on the user's behalf
happens for exactly one reason — a commit is about to run — so the
string was a constant wearing a parameter's clothes. It now names the
commit, and three call-site strings go with it.

The entry gates keep their `action`, and the reason is worth stating
because it's what stopped me deleting the parameter outright. An entry
point often doesn't yet know it will commit at all:

```rust
current_wt.ensure_no_unmerged_paths("merge")?;   // src/commands/merge.rs:179
...
let ResolvedMergeFlags { commit, .. } = flags.resolve(&resolved.merge);   // :187
```

`wt merge --no-commit` resolves its flags *after* that gate, and
requires a clean tree — which an unmerged path is not. So a conflicted
`--no-commit` run hits the entry gate, and `Cannot commit` would be
actively wrong for a command explicitly told not to commit. The two
gates know different things, and now say different things for that
reason.

This also settles the seam #3588 documented as a tradeoff: `wt merge
--no-squash` reporting `Cannot commit` isn't a wrong label to be fixed
later, it's the funnel correctly naming the only step it guards, at a
point where the flags have resolved and the commit is certain.

## `CommitOptions::warn_about_untracked` was dead

It defaulted to `true`, and both assignments set it to `stage_mode ==
StageMode::All` immediately after assigning `stage_mode`:

```rust
options.stage_mode = stage_mode;
options.warn_about_untracked = stage_mode == super::commit::StageMode::All;
```

while the consumer already ANDs with the same condition:

```rust
if self.warn_about_untracked && self.stage_mode == StageMode::All {
```

No caller could make it `false` while the mode was `All`, so the field
never decided anything.

## One home for the `StageMode` → `git add` mapping

The mapping existed as two matches: one in `stage`, one in
`preview_commit` for the `--dry-run` temp index. Adding a variant meant
finding both, and missing the preview would silently produce a
`--dry-run` that disagreed with the real run. `StageMode::add_args` now
owns it. `stage`'s two per-arm `.context()` strings collapse into one,
which costs nothing the args don't already say.

## Also

`src/commands/step/squash.rs` justified its entry gate as refusing
"ahead of the hooks and the LLM call" — but `wt.stage` is ahead of both
too, so that reason no longer distinguished it. The gate is still worth
keeping; what it uniquely precedes is the pre-commit approval prompt, so
the comment says that now.

## Verification

No user-visible message changes, and the tests confirm it rather than
assert it: the pre-merge gate reports **no snapshots to review**. Every
refusal a test pins comes from an entry gate, which kept its name, and
the one funnel-driven snapshot
(`step_commit_refuses_hook_created_conflict`) already read `Cannot
commit`.

`cargo run -- hook pre-merge --yes` green (4562/4562, clippy, fmt,
doctests, doc sync). Also `cargo nextest run --features
shell-integration-tests` (4562/4562) and `cargo clippy --all-targets
--features shell-integration-tests`, which the gate doesn't compile —
the blind spot that matters for removing a struct field.
`RUSTDOCFLAGS=-Dwarnings cargo doc` clean for the new intra-doc link.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:54:17 -07:00
Maximilian Roos 58444440d7 fix(ci-status): classify tea failures by shape; report azure-devops extension (#3597)
Two follow-ups from #3595, both in code that talks to a forge CLI.

## Gitea CI status had the same exit-0 blind spot as the switch path

`tea api` never reads the HTTP status — it copies the response body to
stdout and exits 0 — so the `!output.status.success()` gate in
`src/commands/list/ci_status/gitea.rs` never fired for a 4xx/5xx. #3595
fixed this for `wt switch pr:<n>`; the CI-status backend is the second
instance of the same bug.

The commit-status path was the quieter one: every field of
`GiteaCombinedStatus` carries `#[serde(default)]`, so Gitea's `APIError`
body deserialized as `{state: "", total_count: 0}` — an error
indistinguishable from "this commit has no statuses". The PR-list path
was noisier but less harmful: the error body failed to parse and logged
`Failed to parse tea api pulls JSON`, blaming an API change for what was
an API error.

`tea_api_error` now reads the response shape — the `APIError` body,
whose message feeds the existing retriable sniff — and a non-zero exit
keeps meaning what it always meant, `tea` itself failing (transport
error, no login). A proxy's HTML error page stays unclassified, as
before: sniffing arbitrary bodies is the string-heuristic pile this
removes.

## `wt config show --full` reports the azure-devops extension

The whole `az repos` command group ships in that extension, so without
it CI status stays blank however well `az` is authenticated — a
persistent, user-fixable condition, which is what this surface is for.
`azure_devops_extension_installed` (added in #3595) answers it in JSON:

```console
✓ az installed & authenticated
▲ azure-devops extension not installed; run az extension add --name azure-devops
```

An `az` that can't answer counts as installed, so an unanswered check
never becomes an accusation. The `wt switch pr:<n>` bail now uses the
same sentence — one wording for one condition, which is the one-line
change to `switch_pr_azure_extension_not_installed.snap`.

## Testing

The two new Gitea tests were checked against the unfixed source:
reverting `gitea.rs` alone fails both, so they pin the fix rather than
describe it. The two pre-existing retriable tests kept their exit-1
shape but lost their fabricated payload — `exit 1` alongside `429 Too
Many Requests` is the combination `tea` can't produce — and now carry a
real transport error, which is the case that does exit non-zero. `config
show --full` gets a case per extension state.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:54:15 -07:00
Maximilian Roos c136cbcd6a fix(switch): classify forge-CLI failures by structure, not tool prose (#3595)
Follow-ups from #3589's audit of brittle string matching, plus one live
bug the audit turned up.

## Gitea: the error path was unreachable

From tea's own `cmd/api.go`: `runApi` never inspects `resp.StatusCode` —
it copies the response body to stdout and returns nil. So `tea api`
**exits 0 for 404, 401, and 403**, which put the whole 404/401/403
classification behind an `if !output.status.success()` that those
responses never reach. A missing PR surfaced as `Failed to parse Gitea
API response for PR #N. This may indicate a Gitea API change.` The
fabricated `{"message":"403 Forbidden"}` fixtures hid this by mocking an
exit code tea does not produce.

The response *shape* now discriminates — the PR object, or Gitea's
`APIError` body whose message is surfaced verbatim. A non-zero exit
means `tea` itself failed (no login configured, transport error) and
keeps the existing `cli_api_error` path, which shows tea's own stderr.

## Azure: the install command, from JSON

#3589 dropped the appended `az extension add --name azure-devops`
because detecting the case needed prose matching. `az extension list
--output json` answers it structurally instead, and the whole `az repos`
command group ships in that extension — so it's a precondition of the
call, not one guess among many. The check runs only after a failure, so
the happy path pays nothing, and an `az` that can't answer counts as
installed: an unanswered check never becomes an accusation the user
would act on.

## Statusline detection

`is_statusline_configured` matched the substring `"wt "`, which `newt
status` also satisfies. That is not only a wrong diagnostic line —
`install-statusline` early-returns on it, so a foreign statusline made
install refuse. It now asks for the adjacent token pair `list
statusline`, which is the actual question and stays agnostic to `wt` /
`git-wt` / an absolute path.

## Worktree removal: comment only

The remaining `error.contains("not empty")` stays. There is no
structured form to recover: the text is `git worktree remove`'s own
stderr, so the failure never passes through a Rust `io::Error`, and
`remaining_entries` can't substitute because permission-denied also
leaves entries behind. The comment claiming an `io::ErrorKind` was lost
upstream is corrected.

## Testing

Every changed path is covered by an integration test with realistic
mocks: the four Gitea tests now mock exit 0 with `APIError` bodies
(`gitea_server_error` became `gitea_request_failed`, since a 5xx no
longer exits non-zero), the Azure tests mock `az extension list` on both
sides — present, and failing so the check comes back unanswered — and a
new `install_statusline_foreign_command` test asserts a foreign
statusline gets replaced rather than mistaken for worktrunk's.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 03:44:53 -07:00
Maximilian Roos f1f1b50d85 docs(release): trim the signing archaeology now the path is proven (#3593)
Follow-up to #3590, now that v0.69.2 has shipped a signed Windows binary
— verified against the published asset rather than the logs:

```
git-wt.exe: certificate table 8480 bytes
wt.exe:     certificate table 8472 bytes
c4343fb5…1a43 *worktrunk-x86_64-pc-windows-msvc.zip   (published .sha256 matches)
```

Comments only; no behavior change.

The signing comments were written while the mechanism was still being
guessed at, so they carried the failed attempts — a ten-line account of
the zip-of-a-zip failure, a six-line account of the `…zip.zip`
collision. The path is proven and the workflow now verifies itself, so
what a reader needs is the current mechanism. Trimmed to that.

One thing added rather than removed. `Recompute checksum` sits between
two `continue-on-error` steps and looks like it should fold into the
replace step above it. It must not: the signing steps are tolerant
because publishing can't depend on a self-signed test certificate, while
a checksum that doesn't match the shipped zip has to fail the release.
That asymmetry is invisible in the YAML, so it's now stated — the next
person to simplify this shouldn't have to rediscover it.

> _This was written by Claude Code on behalf of max_
2026-07-25 03:22:07 -07:00
Maximilian Roos 5e03e2863c docs(changelog): document the v0.69.2 drift (#3592)
Eleven commits landed on `main` while #3590 sat in CI, so they ship in
v0.69.2 with no changelog entry. Most are user-facing, and three are
data-loss fixes — exactly the drift the release skill's step-12 check
exists to catch. The tag is held until this lands.

Entries added, most-impactful first:

- **#3589** — two ways shell integration deleted user data on a
substring guess: an rc line that only *quotes* the init command, and a
user's own `conf.d/wt.fish` deleted outright by `install`'s legacy
cleanup. Plus forge detection moving from `host.contains("github")` to
label-wise matching.
- **#3585 + #3591** — truncate-in-place rc writes replaced by
write-temp-then-rename, then generalized to every user-file write.
- **#3578** — despite its `docs(step):` subject this carries three
behavior fixes, including `wt step push` succeeding mid-rebase and
moving the target branch onto a half-replayed history.
- **#3588 + #3587** — conflict markers can no longer reach a commit via
`wt step relocate --commit`, and `wt merge` refuses in its own name.
- **#3554** — OpenCode marker writes could land in another session's
worktree (thanks @4i3n6, who both reported and fixed it).

Not documented, per convention: #3574 (doc comments only, no runtime or
docs-site surface) and the three dependency bumps.

Also corrects `.github/CLAUDE.md`, which lists three required status
checks; there are four — `fast-checks` is required too, confirmed
against the branch-protection API.

Entries verified against the diffs by subagent. Two corrections came
back and are folded in: the #3589 entry originally promised "two of them
deleted" and described only one, and the #3578 entry mis-quoted the
success line as `✓ Pushed to main` when it carries a commit count.

> _This was written by Claude Code on behalf of max_
v0.69.2
2026-07-24 20:27:42 -07:00
Maximilian Roos 6b29c2802b Release v0.69.2 (#3590)
Cuts v0.69.2, and fixes the Windows code-signing path it depends on.

## The signing fix

v0.69.1 shipped an unsigned `wt.exe` under a green run and a
**Completed** SignPath signing request. `archive: false` (#3566) had
already made the GitHub artifact name
`worktrunk-x86_64-pc-windows-msvc.zip`, and SignPath names its download
after the artifact — so with `output-artifact-directory: target/distrib`
the signed zip landed at `worktrunk-x86_64-pc-windows-msvc.zip.zip`,
beside the untouched unsigned build. The checksum step and the release
upload both kept reading the original. Confirmed by parsing the
published asset's PE certificate table: `size=0`.

The download now goes to a scratch directory and whatever single file
lands there replaces the built zip, so SignPath's naming isn't
load-bearing.

## Verification, because this failure is invisible

Signing is `continue-on-error` by design (self-signed test certificate
pending SignPath's OSS review), so nothing in the logs distinguishes
"signed" from "silently unsigned" — which is how it slipped through
twice, two different ways. `.github/verify-windows-signature.py` reads
the zip's PE certificate tables directly, and runs at two points with
distinct jobs:

- **before the overwrite** — an unsigned release is tolerable while the
certificate is a test one; a corrupt one never is, so the replacement
has to verify before it can clobber a good build.
- **after** — reports what the release actually ships, which is the
question the logs never answered.

## Rehearsed before landing

The signing path only runs on a tag, so each question about it used to
cost a release. This chain was instead run on a Windows runner against
the already-published v0.69.1 zip (identical bytes, no build):

```
saved to …\target\signpath\worktrunk-x86_64-pc-windows-msvc.zip.zip
git-wt.exe: certificate table 8480 bytes
wt.exe:     certificate table 8472 bytes
→ mv → target/distrib/worktrunk-x86_64-pc-windows-msvc.zip
target/distrib/worktrunk-x86_64-pc-windows-msvc.zip: all 2 executables signed
worktrunk-x86_64-pc-windows-msvc.zip: OK   (checksum matches)
```

That also settled the open question behind #3556: SignPath returns the
signed zip itself, not a wrapper, so `skip-decompress: true` is correct
— the extract mode does explode it into loose files.

## Release contents

`wt remove`'s fsmonitor sweep (#3581), the troubleshooting doc trim, and
this fix. The two refactors in range (#3582, #3584) are
behavior-preserving and omitted per convention.

Local gate green (4547 passed). Changelog entries verified against the
diffs by subagent; two claims corrected (the doc entry's rationale, and
an overclaim attributing v0.69.0's unsigned binary to this bug rather
than the separate upload failure #3566 fixed). Data-loss surface
reviewed across the cumulative diff.

> _This was written by Claude Code on behalf of max_
2026-07-24 20:02:13 -07:00
Maximilian Roos 7562f7f298 fix(config): route every user-file write through one atomic writer (#3591)
`fs::write` truncates in place, so a crash, a full disk, or a lost power
cable between the truncate and the write leaves the file empty or
half-written. #3585 fixed that for the rc file `wt config shell
uninstall` rewrites and left the rest of the class open, pending a
decision on whether every site should share one writer. It should, so
this is that writer plus the sites it covers.

## Sites

Four were truncating a file worktrunk can't put back:

- `config/user/persistence.rs` — `UserConfig::save` reads the user's
whole `config.toml`, merges, and writes it back. The highest-traffic of
these, and the one not listed in #3585's follow-ups. Its lock is on
`config.toml.lock`, a separate file, so the rename doesn't disturb it.
- `commands/config/update.rs` — the migrated config `wt config update`
writes.
- `commands/config/plugins.rs` — the Claude `settings.json` the
statusline setup merges `statusLine` into. Everything else in that file
is the user's.
- `commands/configure_shell.rs` — the rc file, shell wrapper, and fish
completion `wt config shell install` creates. A half-written wrapper is
one the user's shell sources at startup.

Two already did this correctly, each in its own way:
`write_approvals_file` and the uninstall rewrite from #3585. Both now
call the shared function, so the rule is one place rather than a
convention every site reimplements.

`cache::write_json` and the `-vv` diagnostic report keep the plain
write. A torn write there costs a recompute, not the user's data, and an
fsync per cache entry isn't worth paying for.

## Why not a crate

`atomic-write-file` is the serious candidate: same-directory temp,
Windows and WASI support, and the only one that carries ownership
across, which this helper can't. But it replaces a symlink rather than
writing through it ("if the path of an `AtomicWriteFile` is a symlink to
another file, the symlink is replaced"), so the canonicalize step stays
either way, and it costs `nix` plus `rand`. `atomicwrites` does neither
mode nor symlinks and creates a temp subdirectory per write. `tempfile`
is already a runtime dependency and does the hard part, which left about
ten lines. The comparison is in the function's docstring so it isn't
re-litigated.

## Behavior changes

A newly *created* file lands at `0600` rather than the `0644` a
truncating write leaves under a default umask. An existing target's mode
is copied across, but a fresh temp file is `0600` and there is no
non-racy way to read the umask from std. Everything written here is read
by its own owner (`.bashrc`, `wt.fish`, `config.toml`, `settings.json`),
it fails safe, and `approvals.toml` has shipped this way since it
started using a temp file.

Creating the temp file needs a writable parent directory, which a
truncating write did not, so a read-only directory holding a writable
file now fails with the file left as it was.

## Testing

The three uninstall tests from #3585 now exercise the shared function:
mode preserved with no temp file left behind, the rewrite following a
symlink into a different directory, and the rc file intact when the
write fails. Two new unit tests cover what those don't — the
create-then-replace round trip leaving no stray, and the `0600` a
created file gets.

Also driven through the real CLI rather than only tests. `wt config
update` against a `0644` `config.toml` symlinked into a dotfiles
directory rewrote the real file and left the link, the mode, and the
directory as they were. `wt config shell install` into a fresh `$HOME`
produced the `0600` rc file, wrapper, and completion above.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 19:50:40 -07:00
Maximilian Roos 321ffe838c fix(step): gate every staging path on an unmerged index (#3588)
`wt step relocate --commit` committed conflict markers, the bug #3579
fixed everywhere else. It stages with `git add -A` and calls
`CommitGenerator::commit_staged_changes` directly, so it reached neither
of that PR's two gates:

```console
$ git merge side          # CONFLICT (content): Merge conflict in f
$ wt step relocate --commit
◎ Committing changes in feature...
✓ Committed changes @ eb4cf19
$ git show HEAD:f
<<<<<<< HEAD
feature
=======
side
>>>>>>> side
```

`MERGE_HEAD` is gone and the working tree is clean afterwards, so the
broken merge reads as finished.

## The gate belongs at the staging step, not the commit

The obvious funnel is `commit_staged_changes`, the one call all three
staging paths share. It cannot work, and the reason rules the shape out
rather than merely disfavoring it: every caller stages *before* that
call, and `git add -A` collapses an unmerged path's three index stages
into one entry. After it, `git diff --diff-filter=U` returns nothing and
git itself stops refusing.

```console
$ git ls-files -u
100644 df967b9… 1	f
100644 ba2906d… 2	f
100644 2299c37… 3	f
$ git add -A
$ git diff --name-only --diff-filter=U    # empty
$ git commit -m x                         # succeeds
```

A check there passes vacuously in exactly the case it exists for.

So the funnel sits one step earlier. `WorkingTree::stage(StageMode,
action)` refuses, then stages, with nothing in between. It is now the
only path to `git add` against a worktree's real index (every other `git
add` writes a throwaway index via `temp_index`), so a fifth entrypoint
can neither forget the gate nor order it after the staging that destroys
the evidence. It also replaces three hand-rolled copies of the
`StageMode` to `git add` match, so the callers come out smaller than
they went in.

## The second gate is not belt-and-suspenders

The callers keep their early refusals, which guard a different window:
those run before hooks, prompts, and the LLM call, so a doomed commit
runs no project commands. That placement leaves a hole the early refusal
structurally cannot see, because a `pre-commit` hook is arbitrary
project code, free to conflict the index after the gate has passed.

```console
$ cat .config/wt.toml
pre-commit = "git merge side"
$ wt step commit --yes
◎ Running pre-commit project hook
✓ Committed changes @ 9e89c76        # markers and all
```

So `wt step commit` carried the same bug, reachable through any project
config. That path isn't experimental, which may make it the more
consequential half of this PR.
`test_step_commit_refuses_hook_created_conflict` pins it.

## relocate skips rather than aborting

An unresolved conflict is a per-worktree blocker the user has to fix by
hand, like a locked worktree. relocate is a bulk command whose every
other blocker (locked, uncommitted, template_error, occupied target)
skips one worktree and carries on. Aborting the run would leave the
other N-1 unrelocated, and would make passing `--commit` strictly less
capable than omitting it.

```console
$ wt step relocate --commit
▲ Skipping feature (1 path with unresolved conflicts)
  conflict.txt
✓ Relocated other: ~/code/wrong-clean → ~/code/myproject.other
```

`--format=json` records it as `reason: "unmerged"`, alongside the
existing codes.

Relocating a conflicted worktree *without* `--commit` is unchanged.
Confirmed by hand that `git worktree move` carries the unmerged index,
`MERGE_HEAD`, and the markers across, and that git still refuses to
commit at the new path. Nothing stages there, so nothing is lost.

## Detection stays git's

Nothing greps file contents for `<<<<<<<`. The whole chain is one
structured question, `git diff --name-only --diff-filter=U -z`, which
asks git which paths its index records as unmerged: the same question
`git commit` asks itself. A content grep would false-positive on this
very repo, whose docstrings contain the markers. The only marker-content
checks in the diff are two test assertions, that the markers stay on
disk for the user and never reach a commit.

## Merged with #3587

#3587 landed while this was in flight (as did #3578, whose `wt step
push` refusal test shares the same spot in `merge.rs`) and hoisted
`ensure_no_unmerged_paths("merge")` to `wt merge`'s entry, so merge
refuses in its own name. The two are complementary: that PR gives each
entry point a gate in the name the user typed, and this one guarantees
the staging step itself is gated no matter who reaches it. Both sides
edited the same docstring and added a test at the same spot; the merge
keeps both paragraphs, lists all four entry points, and keeps both
tests.

The interaction leaves one narrow seam. `wt merge --no-squash`, with a
`pre-commit` hook that conflicts the index, now refuses with `Cannot
commit` rather than `Cannot merge`, because the entry gate already
passed and the funnel names the step it fired in. Fixing the wording
would mean threading a caller-supplied noun into `CommitOptions`, which
#3587 considered and rejected as the wrong shape, so it stays. The
default squash path isn't affected: squash stages before its hooks, so
the funnel fires ahead of them.

## Verification

Three integration tests, each asserting HEAD is unmoved rather than only
matching the message:

- `test_relocate_refuses_unmerged_paths`, the reported bug.
- `test_relocate_unmerged_skips_only_that_worktree`: one conflicted
worktree, one merely dirty. The sibling still relocates, and JSON
carries `reason: "unmerged"`.
- `test_step_commit_refuses_hook_created_conflict`, driven from a
`pre-commit` hook, so it can only pass through the check inside `stage`.

Gate green on the merged head (4557/4557, lints, fmt, doc sync), plus
`cargo nextest run --features shell-integration-tests` (4546/4546
pre-merge) and `cargo clippy --all-targets --features
shell-integration-tests`, which the gate doesn't compile.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 19:34:33 -07:00
Maximilian Roos b623d7f951 fix(shell): don't delete user data that only mentions the init command
Three places decided whether text was worktrunk's by testing a blob for
substrings. Two of them deleted the user's data when they guessed wrong.

`wt config shell uninstall` asked two unlinked questions of an rc line —
is an exec keyword anywhere in it, and is `<name> config shell init`
anywhere in it — so `alias setup='echo "run: wt config shell init fish |
source"'` classified as worktrunk's and was removed. One question
replaces both: does the line invoke the init command, with the name in
command position? The exec-keyword scan is gone rather than kept
alongside; it read English words out of the whole line and rejected
nothing the positional rule doesn't.

`wt config shell install` had the same defect one file over and one step
worse: `is_worktrunk_managed_content` tested the whole file for
`config shell init` and `| source`, and `cleanup_legacy_fish_conf_d`
deleted a user's own `conf.d/wt.fish` outright — during an install, which
never offered to remove anything. The cmd-agnostic twin beside it already
answered correctly, so the substring version is gone.

Uninstall now prints every line it takes, in the confirmation and again
after removal, since shell quoting stays undecidable without running the
line.

Sweeping for the same shape found it in `az` failure classification,
where `stderr.contains("login")` alone meant "not authenticated" and
`bail!` discarded the real error; `az` states the remedy itself, so its
text now reaches the user. Provider host matching moved from
`contains("github")` to label-boundary comparison, so
`dev.azure.com.attacker.example` and `mygithubmirror.com` no longer
resolve to Azure DevOps and GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 19:25:31 -07:00
4i3n6 b00e6b612a fix(plugin): scope OpenCode marker commands to the plugin's directory (#3554)
Follow-up to #3552, as invited there.

The OpenCode host injects the process-global Bun shell as `$` — a single
instance shared by every plugin loaded in the process. The
activity-marker plugin issued its commands unscoped, so each one ran in
whatever the process-wide cwd was at spawn time; under concurrent
parallel-agent sessions that means a marker write can land in a
directory other than the one the event came from.

This pins every command with the promise-level `.cwd(directory)`, per
the review notes on the issue:

- `directory` pulled from `PluginInput` (the factory now destructures `{
$, directory }`), and `session.deleted` scopes the same way as `set`.
- Promise-level `.cwd(directory)` only — the instance-level `$.cwd(...)`
would mutate the shared default for every plugin in the host process, so
the call-site comment documents why the per-command form is
load-bearing.

Scope note, consistent with the issue discussion: this closes the "runs
`wt` in the wrong directory" mechanism on the plugin side; it does not
claim to explain the parent-directory `rename(2)` from the original
report. The host-level question (injecting a scoped shell instead of the
global one) belongs upstream with opencode — happy to raise it there as
discussed.

Co-authored-by: 4i3n6 <4i3n6@users.noreply.github.com>
2026-07-24 19:23:36 -07:00
Worktrunk Bot d13d4b10b7 docs(worktree): correct "^" resolution terminology (#3574)
Nightly survey finding. Two doc comments in
`src/git/repository/worktrees.rs` describe the `"^"` worktree-name
symbol as resolving to "main", but the code resolves it via
`self.default_branch()` — which is `main`, `master`, or any renamed
default:

- `resolve_worktree_name` summary line: `"^" to main` → `"^" to the
default branch` (the `# Arguments` block right below it already says
"default branch").
- `resolve_worktree` `# Arguments`: `"^" for main worktree` → `"^" for
the default branch's worktree`.

This aligns the comments with the CLAUDE.md **Terminology** convention
("default branch — the branch (main, master, …), not 'main branch'"; use
consistently in docs, help text, and code comments). Behavior is
unchanged; only comments are touched.

No test is included — this is a documentation-comment-only change with
no runtime surface to exercise.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-24 19:23:19 -07:00
Maximilian Roos b17a1364f1 docs(step): render wt step rebase & wt step push on the docs site (#3578)
Follow-up from #3568, which named these two as the one plain oversight
in its audit of `after_long_help` bodies that never reach a docs page.

`wt step rebase` and `wt step push` were the only two of twelve step
operations with no `<!-- subdoc: -->` marker, so their help was
terminal-only, and the only two bullets in `## Operations` left
unlinked. Both markers are added between `squash` and `diff`, per the
ordering invariant in `src/cli/step.rs`.

Both openers restated their `about` line, which reads as immediate
self-repetition on the web where `combine_command_docs()` concatenates
the two. Both bodies are rewritten.

## What reviewing the text against the code turned up

The old rebase body said "Conflicts abort immediately; use `git rebase
--abort` to recover", which is self-contradictory and wrong. Nothing
aborts: the worktree is left mid-rebase with git's conflict markers, and
git's output names `--continue`, `--skip`, and `--abort`. `wt merge`'s
pipeline step 3 carried the same sentence.

Four review passes then falsified nine more claims, every one reproduced
against a scratch repo before editing:

- **`--no-ff` runs no `git push` at all.** It builds the merge with
`commit-tree` + `update-ref` and syncs the worktree with `read-tree`; a
`-vv` trace shows zero `git push` invocations. The opener attributed the
whole command to `git push` with `--no-ff` as an example four lines
below. Its worktree sync is also best-effort, so the ref can move while
the worktree stays behind.
- **The Outcomes table was falsified by the upstream-swap topology.**
With local `main` diverged and behind `origin/main`, `wt step rebase
main` prints `Already up to date with origin/main` — `main` is not an
ancestor of the branch, and the result names a ref never typed.
- **"Nothing here reaches a remote" was false on a fresh clone.** With
no target argument and no cached `worktrunk.default-branch`,
`default_branch()` falls through to `git ls-remote`. The claim is now
"no commits leave the repository", which holds unconditionally.
- **The generated Arguments table contradicted the prose.** `[TARGET]
Target branch` sat two paragraphs below "the target is any commit-ish";
a blind reader nearly concluded rebase needs a branch name.
- **The table implied mutually exclusive rows.** `is_rebased_onto` is
checked first, and a branch at the target's tip satisfies both of the
first two.
- Plus: an orphan branch matched no row; "git's own output names the
ways out" is false under `advice.mergeConflict false`; "refused rather
than forced" raised the force question without closing it.

Adjacent, in files this change already touched: `wt merge`'s step 3
contradicted the new table and step 5 restated `wt step push`'s rule
with no link, so half the pipeline deferred and half repeated. Step 2
promised a backup ref unconditionally, but `create_safety_backup` runs
only when working-tree changes were staged — a clean-tree squash
rewrites commits and writes no `refs/wt-backup/` ref. That is a
data-safety claim, so it is corrected rather than left.
`docs/CLAUDE.md`'s subdoc "Use cases" cited `wt config create`, which
has no marker and no section.

## The code changes

**An annotated-tag target was never peeled.** Adding a tag example to
the rebase help is what made it worth running, and it did not work.
`is_rebased_onto` compared `git merge-base`, which peels an annotated
tag to its commit, against a bare `rev-parse`, which returns the tag
object's SHA. Those never match, so an annotated-tag target always
looked like it needed a rebase:

```
annotated   v1.2.0     → "outcome": "rebased"     (HEAD unchanged)
lightweight v1.2.0-lw  → "outcome": "up_to_date"   (same graph, same commit)
```

Fixed at the root by peeling with `^{commit}`, rather than documenting
the quirk. The test pairs the two tag types on one commit so the control
is a single factor away; it fails without the fix.

The reviewer caught that this test had gone missing, and it was right
about the consequence — without it, removing the `^{commit}` peel passes
CI. The cause was a merge resolution on this branch: `checkout --theirs`
on `tests/integration_tests/merge.rs` took main's whole file, dropping
`test_step_rebase_annotated_tag_is_peeled` along with the duplicate
squash test it was meant to drop. Restored, and re-verified in both
directions.

**`wt step push` ran out of a half-finished operation.** Mid-rebase, the
detached HEAD is a linear extension of the target, so the fast-forward
check passed and `wt step push main` printed `✓ Pushed to main (1
commit)` — moving the target branch onto a half-replayed history while
the worktree kept its conflict markers and the rebase stayed open. It
now runs the `ensure_no_operation_in_progress` gate `wt step rebase` and
`wt merge` have used since 84cbb0489.

#3579 landed while this was open and closed the same class for the
staging commands, with a better predicate for them: `git add -A`
collapses an unmerged path's stages, so the index is what knows, and an
index read also catches a conflicted `git stash pop` that writes no
state file. `wt step push` was scoped out of that, correctly — it stages
nothing, so an index read cannot speak for it. Its hazard is HEAD
itself, which is what the operation gate answers. The merge takes main's
`squash.rs` and its `test_step_squash_refuses_mid_merge` wholesale and
drops this branch's version of both; the gate's docstring now hands the
unresolved-conflict question to `WorkingTree::ensure_no_unmerged_paths`
instead of claiming it, and the rebase help enumerates all four gated
commands.

**A target worktree registered after its directory is gone got two
different answers.** The fast-forward died inside receive-pack — `fatal:
exec 'update-index': cd to '…' failed`, `! [remote rejected] HEAD ->
main (Up-to-date check failed)` — with the ref untouched, while
`--no-ff` moved the ref with plumbing of its own and skipped the sync,
so it succeeded over the same broken registration. Both refuse now with
the branch named and `git worktree prune` as the remedy, which is what
retires the two `.exists()` checks that papered over the state
downstream. `--no-ff` succeeding here is the one behavior this takes
away; a stale registration is worth one error rather than half a
success.

## Found and not fixed here

**Ignored files in a target worktree are silently overwritten, and `git
worktree lock` does not stop it.** `git status --porcelain` omits
ignored files, so the overlap check cannot see them and the stash does
not take them. Reproduced against the FAQ's own example
(`docs/content/faq.md:180` recommends the lock for "precious ignored
data"): a locked target worktree holding `db.sqlite` had it replaced by
the branch's tracked file, exit 0, no warning. The lock scopes to
removal only. A pathspec-limited probe of the push range detects it
without enumerating large ignored trees — `git -C <target-wt> ls-files
-o --ignored --exclude-standard -z -- <push_files>` names the colliding
file and stays silent about a 200-file ignored `target/` — but whether a
collision should refuse is a policy call, so it is left out.

## Verification

`wt hook pre-merge --yes` on the merged tree → exit 0, 4554 tests, no
pending snapshots. `zola build` clean, both anchors resolving and
merge.md's cross-page link landing. Every factual claim checked by
running `wt` against a purpose-built repo, and each of the three code
fixes has a test that fails without it.

> _This was written by Claude Code on behalf of Maximilian Roos_

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:16:20 -07:00
Worktrunk Bot eda641546f chore: update tend workflows (0.1.11 → 0.1.12) (#3573)
Automated nightly regeneration of tend's workflow files via `uvx
tend@latest init`, picking up the tend release since the last regen.

**tend version:** 0.1.11 → 0.1.12

**Notable changes:**

- **Notifications workflow tolerates transient `gh api` blips** — the
notifications fetch now retries on non-JSON responses (e.g. an HTML
error page returned with a 200 during an API blip) and skips the cycle
cleanly instead of aborting the step red (max-sixty/tend#780).
- **`review-runs` fails loud on transient `gh` errors** — a failed run
enumeration no longer reports a false all-clear; it surfaces the error
so the run isn't silently treated as clean (max-sixty/tend#784).
- **`running-in-ci` guidance** — the bot no longer asks outside
contributors to do work it can't do itself (max-sixty/tend#789).
- **Harness bumps** — Claude harness version bumped to 2.1.215
(max-sixty/tend#782) and the default harness timeout raised to 5h50m
(max-sixty/tend#790).

Compare: https://github.com/max-sixty/tend/compare/0.1.11...0.1.12

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-24 18:55:22 -07:00
dependabot[bot] 4ea2fe46a3 chore: bump taiki-e/install-action from 2.84.0 to 2.85.0 (#3570)
Bumps
[taiki-e/install-action](https://github.com/taiki-e/install-action) from
2.84.0 to 2.85.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/taiki-e/install-action/releases">taiki-e/install-action's
releases</a>.</em></p>
<blockquote>
<h2>2.85.0</h2>
<ul>
<li>
<p>Support <code>wild</code> (alias: <code>wild-linker</code>). (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1949">#1949</a>)</p>
</li>
<li>
<p>Support <code>bpf-linker</code>. (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1950">#1950</a>)</p>
</li>
<li>
<p>Support <code>rafn</code>. (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1935">#1935</a>,
thanks <a
href="https://github.com/DarkWanderer"><code>@​DarkWanderer</code></a>)</p>
</li>
<li>
<p>Update <code>cargo-neat@latest</code> to 0.5.1.</p>
</li>
<li>
<p>Update <code>zizmor@latest</code> to 1.28.0.</p>
</li>
<li>
<p>Update <code>wasmtime@latest</code> to 47.0.2.</p>
</li>
<li>
<p>Update <code>uv@latest</code> to 0.11.31.</p>
</li>
<li>
<p>Update <code>syft@latest</code> to 1.49.0.</p>
</li>
</ul>
<h2>2.84.1</h2>
<ul>
<li>
<p>Update <code>wasmtime@latest</code> to 47.0.1.</p>
</li>
<li>
<p>Update <code>wasm-tools@latest</code> to 1.254.0.</p>
</li>
<li>
<p>Update <code>uv@latest</code> to 0.11.30.</p>
</li>
<li>
<p>Update <code>mise@latest</code> to 2026.7.11.</p>
</li>
<li>
<p>Update <code>cargo-neat@latest</code> to 0.5.0.</p>
</li>
<li>
<p>Update <code>cargo-crap@latest</code> to 0.3.1.</p>
</li>
<li>
<p>Update <code>biome@latest</code> to 2.5.5.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md">taiki-e/install-action's
changelog</a>.</em></p>
<blockquote>
<h2>[2.85.0] - 2026-07-23</h2>
<ul>
<li>
<p>Support <code>wild</code> (alias: <code>wild-linker</code>). (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1949">#1949</a>)</p>
</li>
<li>
<p>Support <code>bpf-linker</code>. (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1950">#1950</a>)</p>
</li>
<li>
<p>Support <code>rafn</code>. (<a
href="https://redirect.github.com/taiki-e/install-action/pull/1935">#1935</a>,
thanks <a
href="https://github.com/DarkWanderer"><code>@​DarkWanderer</code></a>)</p>
</li>
<li>
<p>Update <code>cargo-neat@latest</code> to 0.5.1.</p>
</li>
<li>
<p>Update <code>zizmor@latest</code> to 1.28.0.</p>
</li>
<li>
<p>Update <code>wasmtime@latest</code> to 47.0.2.</p>
</li>
<li>
<p>Update <code>uv@latest</code> to 0.11.31.</p>
</li>
<li>
<p>Update <code>syft@latest</code> to 1.49.0.</p>
</li>
</ul>
<h2>[2.84.1] - 2026-07-22</h2>
<ul>
<li>
<p>Update <code>wasmtime@latest</code> to 47.0.1.</p>
</li>
<li>
<p>Update <code>wasm-tools@latest</code> to 1.254.0.</p>
</li>
<li>
<p>Update <code>uv@latest</code> to 0.11.30.</p>
</li>
<li>
<p>Update <code>mise@latest</code> to 2026.7.11.</p>
</li>
<li>
<p>Update <code>cargo-neat@latest</code> to 0.5.0.</p>
</li>
<li>
<p>Update <code>cargo-crap@latest</code> to 0.3.1.</p>
</li>
<li>
<p>Update <code>biome@latest</code> to 2.5.5.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/taiki-e/install-action/commit/7572810d7dd469b651bb7793945692cf78da5dd7"><code>7572810</code></a>
Release 2.85.0</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/d73fed906d8b5a8fbe1165c7635ef41dbf5e6ccd"><code>d73fed9</code></a>
Update changelog</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/20d0440ac8346c2da5bfa9bc79b6d50c30ee7658"><code>20d0440</code></a>
Support bpf-linker (<a
href="https://redirect.github.com/taiki-e/install-action/issues/1950">#1950</a>)</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/05a01b63a23569d0bf6dde483954e312dce7d417"><code>05a01b6</code></a>
Update vacuum manifest</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/23a3cce147f42d0975d244f68a4af961ccd8fd53"><code>23a3cce</code></a>
Update <code>cargo-neat@latest</code> to 0.5.1</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/54ede987e296f0fc0b43f3f75d496577fad36a9c"><code>54ede98</code></a>
Support rafn (<a
href="https://redirect.github.com/taiki-e/install-action/issues/1935">#1935</a>)</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/411aa4ba3c7fb6ad60a7421c46e881a3a1ceb8ad"><code>411aa4b</code></a>
Support wild (<a
href="https://redirect.github.com/taiki-e/install-action/issues/1949">#1949</a>)</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/4427ee328dd40578670ed6724b4a766207e21f0e"><code>4427ee3</code></a>
Update <code>zizmor@latest</code> to 1.28.0</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/68a5a96ea71119471050840e296140c66135d7b8"><code>68a5a96</code></a>
Update <code>wasmtime@latest</code> to 47.0.2</li>
<li><a
href="https://github.com/taiki-e/install-action/commit/f276a80345c4641da04f6613887cc496c332c232"><code>f276a80</code></a>
Update <code>uv@latest</code> to 0.11.31</li>
<li>Additional commits viewable in <a
href="https://github.com/taiki-e/install-action/compare/v2.84.0...v2.85.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=taiki-e/install-action&package-manager=github_actions&previous-version=2.84.0&new-version=2.85.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 18:55:20 -07:00
dependabot[bot] b636ae1a11 chore: bump max-sixty/tend from 0.1.11 to 0.1.12 (#3569)
Bumps [max-sixty/tend](https://github.com/max-sixty/tend) from 0.1.11 to
0.1.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/max-sixty/tend/releases">max-sixty/tend's
releases</a>.</em></p>
<blockquote>
<h2>0.1.12</h2>
<h3>Improved</h3>
<ul>
<li><strong>The default harness timeout rises from 3 hours (10800s) to
5h50m (21000s)</strong> in both Claude harnesses
(<code>claude/action.yaml</code> headless,
<code>claude-interactive/action.yaml</code> PTY), leaving a 10-minute
buffer under GitHub Actions' hard 6-hour job cap instead of cutting long
sessions off at 3 hours. (<a
href="https://redirect.github.com/max-sixty/tend/pull/790">#790</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong>The bot no longer asks an upstream maintainer to do
verification work it couldn't do itself.</strong> When a check needs
hardware or an environment CI doesn't have, the
<code>running-in-ci</code> skill now escalates in order — do it
yourself, add the capability to your own repo, ask a contributor, ask
your own maintainer — and never hands the ask outward to someone
reviewing the bot's change as a favor. (<a
href="https://redirect.github.com/max-sixty/tend/pull/789">#789</a>)</li>
<li><strong><code>list-recent-runs.sh</code> fails loud on a transient
<code>gh</code> API error instead of silently reporting zero
runs.</strong> A dropped <code>gh workflow list</code>/<code>gh run
list</code> call previously read as a false all-clear that permanently
skipped that window; it now retries with backoff and exits non-zero if
every attempt fails. (<a
href="https://redirect.github.com/max-sixty/tend/pull/784">#784</a>)</li>
<li><strong>The notifications workflow tolerates a transient non-JSON
response from the GitHub API</strong> instead of failing the step
outright. (<a
href="https://redirect.github.com/max-sixty/tend/pull/780">#780</a>)</li>
</ul>
<h3>Internal</h3>
<ul>
<li>Bumped pinned <code>claude_version</code> to 2.1.215. (<a
href="https://redirect.github.com/max-sixty/tend/pull/782">#782</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/max-sixty/tend/blob/main/CHANGELOG.md">max-sixty/tend's
changelog</a>.</em></p>
<blockquote>
<h2>0.1.12</h2>
<h3>Improved</h3>
<ul>
<li><strong>The default harness timeout rises from 3 hours (10800s) to
5h50m (21000s)</strong> in both Claude harnesses
(<code>claude/action.yaml</code> headless,
<code>claude-interactive/action.yaml</code> PTY), leaving a 10-minute
buffer under GitHub Actions' hard 6-hour job cap instead of cutting long
sessions off at 3 hours. (<a
href="https://redirect.github.com/max-sixty/tend/pull/790">#790</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li><strong>The bot no longer asks an upstream maintainer to do
verification work it couldn't do itself.</strong> When a check needs
hardware or an environment CI doesn't have, the
<code>running-in-ci</code> skill now escalates in order — do it
yourself, add the capability to your own repo, ask a contributor, ask
your own maintainer — and never hands the ask outward to someone
reviewing the bot's change as a favor. (<a
href="https://redirect.github.com/max-sixty/tend/pull/789">#789</a>)</li>
<li><strong><code>list-recent-runs.sh</code> fails loud on a transient
<code>gh</code> API error instead of silently reporting zero
runs.</strong> A dropped <code>gh workflow list</code>/<code>gh run
list</code> call previously read as a false all-clear that permanently
skipped that window; it now retries with backoff and exits non-zero if
every attempt fails. (<a
href="https://redirect.github.com/max-sixty/tend/pull/784">#784</a>)</li>
<li><strong>The notifications workflow tolerates a transient non-JSON
response from the GitHub API</strong> instead of failing the step
outright. (<a
href="https://redirect.github.com/max-sixty/tend/pull/780">#780</a>)</li>
</ul>
<h3>Internal</h3>
<ul>
<li>Bumped pinned <code>claude_version</code> to 2.1.215. (<a
href="https://redirect.github.com/max-sixty/tend/pull/782">#782</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/max-sixty/tend/commit/f90bacaf61dc655fa6331ff558006cb730501772"><code>f90baca</code></a>
chore: release 0.1.12 (<a
href="https://redirect.github.com/max-sixty/tend/issues/791">#791</a>)</li>
<li><a
href="https://github.com/max-sixty/tend/commit/a843719b8c32b66a5e2c4408b7512dccb93771d7"><code>a843719</code></a>
chore: bump harness timeout default to 5h50m (<a
href="https://redirect.github.com/max-sixty/tend/issues/790">#790</a>)</li>
<li><a
href="https://github.com/max-sixty/tend/commit/ba5a7dee5fe23776255a5c759137269bd5a09c0a"><code>ba5a7de</code></a>
fix(running-in-ci): don't ask outsiders to do work the bot can't do
itself (#...</li>
<li><a
href="https://github.com/max-sixty/tend/commit/bbeb9d2cc5df849ed3cc517c8f5ce59ef38f7066"><code>bbeb9d2</code></a>
fix(list-recent-runs): fail loud on transient gh errors instead of a
false al...</li>
<li><a
href="https://github.com/max-sixty/tend/commit/8d217418307c9365e2a8e1d67c8e3435c6c675e6"><code>8d21741</code></a>
chore: bump claude_version to 2.1.215 (<a
href="https://redirect.github.com/max-sixty/tend/issues/782">#782</a>)</li>
<li><a
href="https://github.com/max-sixty/tend/commit/c3fd0c46d5f9bf4d202ffe7991b56e042f2b0348"><code>c3fd0c4</code></a>
fix(notifications): tolerate transient non-JSON gh api responses (<a
href="https://redirect.github.com/max-sixty/tend/issues/780">#780</a>)</li>
<li><a
href="https://github.com/max-sixty/tend/commit/44beffddf7930a82932ab7d7f70a9697b5d1200b"><code>44beffd</code></a>
chore: regenerate workflows with tend 0.1.11 (<a
href="https://redirect.github.com/max-sixty/tend/issues/778">#778</a>)</li>
<li>See full diff in <a
href="https://github.com/max-sixty/tend/compare/0.1.11...0.1.12">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=max-sixty/tend&package-manager=github_actions&previous-version=0.1.11&new-version=0.1.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 18:55:17 -07:00
Maximilian Roos 0ad5cc8ce5 fix(shell): rewrite rc files atomically on uninstall (#3585)
`wt config shell uninstall` ended its rc rewrite with `fs::write`, which
truncates in place. A crash, a full disk, or a lost power cable between
the truncate and the write left `~/.bashrc`, `~/.zshrc`, or a PowerShell
profile empty or half-written, taking every line the user ever added to
it. That is the silent loss CLAUDE.md's Data Safety section rules out.

The new content now goes to a temp file created beside the target and
renamed over it, the same shape `write_approvals_file` already uses. The
rc file ends up either entirely the old version or entirely the new one.

Two details a bare `NamedTempFile::new()` plus `persist()` gets wrong:

- **Symlinks.** Dotfile managers (Mackup, chezmoi, stow) symlink
`~/.zshrc` into a repo or a synced folder. `fs::write` followed the link
and updated the real file; a rename would replace the link itself with a
regular file and silently detach the user's setup. The target is
resolved first, so the link survives.
- **Mode.** A fresh temp file is `0600`, an rc file is typically `0644`.
The target's mode is copied onto the temp file before the rename.

Ownership is not preserved: the replacement belongs to the running user,
so a root-run uninstall against another user's rc file would change its
owner. Restoring it needs a `chown`, which no current dependency
exposes.

## Why only uninstall

The other user-file writes in this module do not have the same exposure:

- `configure_shell_file` appends to an existing rc with
`OpenOptions::append`. A crash there can leave a partial integration
line, but it never touches what was already in the file. Converting it
to a full-file rewrite would introduce the truncate window this PR is
removing.
- That function's create branch only runs when the file does not exist.
- `configure_wrapper_file` and the fish completion write replace files
whose entire content worktrunk generates, and `wt config shell install`
regenerates them.

Uninstall is the only place worktrunk truncates a file whose other
contents belong to the user.

## Behavior change

Creating the temp file needs write access to the target's directory,
which a truncating write did not. Uninstall now fails where a read-only
directory holds a writable rc file, leaving the rc file as it was.

## Testing

Three unit tests, Unix-gated since mode and symlink semantics differ on
Windows: mode preserved with no temp file left behind, the rewrite
following a symlink into a different directory, and the rc file intact
when the write fails. Each was checked against the alternative it is
meant to catch. The failure test fails under the old `fs::write`; the
mode and symlink tests fail under a naive `NamedTempFile::new()` plus
`persist()`. I also ran the real CLI against a fake home whose `.bashrc`
is a 0644 file behind a symlink, and confirmed the link, the mode, and
the surrounding lines all survived.

Their shared helper and expected-output const are `#[cfg(unix)]` for the
same reason, which the first push got wrong: used only behind
`#[cfg(unix)]`, they read as live to the local gate and as dead code to
`test (windows)` under `-D warnings`. `tests/CLAUDE.md` now records that
blind spot, since the gate runs one platform and structurally cannot
catch this class.

## Same bug class elsewhere, not fixed here

Two writes outside this module read a user-authored file, modify it, and
write it back with a truncating `fs::write`:

- `src/commands/config/update.rs:114` rewrites the user's `wt.toml` or
user config with migrated content.
- `src/commands/config/plugins.rs:144` merges `statusLine` into the
user's Claude `settings.json`.

Fixing those wants a prior decision on whether every site,
`write_approvals_file` included, should go through one shared writer.
That is worth its own review rather than a rider on this one.

> _This was written by Claude Code on behalf of max_
2026-07-24 18:43:29 -07:00
Maximilian Roos b6b74574a8 fix(merge): refuse unresolved conflicts in merge's own name (#3587)
Follow-up to #3579, which added the index gate that keeps conflict
markers out of a commit. The gate works on `wt merge`; it just doesn't
refuse in merge's name.

```console
$ git stash pop
CONFLICT (content): Merge conflict in f
$ wt merge --yes
✗ Cannot squash: 1 path with unresolved conflicts
  f
$ wt merge --yes --no-squash
✗ Cannot commit: 1 path with unresolved conflicts
  f
```

`wt merge` stages through two different steps, so it borrowed whichever
noun it happened to delegate to. Its sibling gate on the same command
says `Cannot merge`, so one command named itself three ways depending on
flags.

Threading a caller-supplied noun into `handle_squash` and
`CommitOptions` would fix the wording, and it's the wrong shape: `wt
merge` already runs `ensure_no_operation_in_progress("merge")` at its
entry, in its own name. Each command gates for itself; the index gate is
the one that hadn't been hoisted yet. So it moves up next to its
sibling:

```rust
repo.ensure_no_operation_in_progress("merge")?;
current_wt.ensure_no_unmerged_paths("merge")?;
```

The inner guards stay where they are — `wt step commit` and `wt step
squash` are entry points too, and they keep answering in their own
names.

Refusing at the entry also puts it ahead of the approval prompts and the
hook plan, rather than partway through the flow.

`--no-commit` changes which error it hits. It used to reach the
dirty-tree check first:

```console
$ wt merge --yes --no-commit
✗ Cannot merge with --no-commit: feature has uncommitted changes
  UU f
↳ Commit or stash changes first
```

Committing is the one thing that must not happen here, and stashing a
conflicted index isn't something git will do either.

## Verification

Every entry point, against a real repo stopped on a conflicted `git
stash pop`, with HEAD unmoved in each:

| | before | after |
|---|---|---|
| `wt merge --yes` | `Cannot squash` | `Cannot merge` |
| `wt merge --yes --no-squash` | `Cannot commit` | `Cannot merge` |
| `wt merge --yes --no-commit` | `Cannot merge with --no-commit: …
uncommitted changes` | `Cannot merge` |
| `wt step squash --yes` | `Cannot squash` | unchanged |
| `wt step commit --yes` | `Cannot commit` | unchanged |

`test_merge_refuses_unmerged_paths` pins the first row. It's driven from
the conflicted-stash-pop helper, which writes no state file, so it can
only pass through the index read — and it asserts HEAD is unmoved, not
just the message.

Full gate green (4548/4548, lints, fmt, doc sync) on the rebased head,
plus `cargo clippy --all-targets --features shell-integration-tests`.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 18:41:35 -07:00
Maximilian Roos 6c1fc91a12 refactor(statusline): share the worktree lookup and collection plan (#3582)
The two statusline surfaces — `run_json` (`--format=json`) and
`git_status_segments` (the rendered line) — each carried their own copy
of "find my worktree, decide if it's home, build the item", and their
own literal `CollectOptions`, kept in sync by a comment reading "Same
plan as the other statusline path". Both now come from one place:
`statusline_options()` holds the shared plan (all columns, full gates,
CI on, summary off, untracked counted in the working diff), and
`current_worktree_item()` holds the lookup, returning `Option<ListItem>`
so it reports "no worktree here" rather than deciding what to print —
the callers render that case differently (an empty JSON result vs. a
bare branch segment).

Behavior-preserving. Verified by building the pre- and post-change
binaries side by side and diffing their output across four directories
(a dirty worktree, the primary worktree, `$HOME`, `/tmp`) ×
default/`--format=json`, plus the `--format=claude-code` stdin path:
byte-identical output and matching exit codes in every case.

One ordering change is not a pure textual move and is where review
attention is best spent: `git_status_segments`'s "no default branch →
just show the branch" early-out now runs after `primary_worktree()`
instead of before it. Both resolve through the same cached
`default_branch()`, so there's no extra work and no new path to the
wire.

Two of the moved lines were the branch-name fallbacks at the end of
`git_status_segments`, both uncovered on main and now counted as patch
lines. Rather than leave them, the second one is gone —
`format_statusline_segments` pushes the branch segment unconditionally,
so the `segments.is_empty()` fallback below it was unreachable — and the
first, "no default branch to compare against", gets the test it never
had: a repo with two branches, neither named main/master/develop/trunk
and no remote, leaves `default_branch()` nothing to infer from, and the
statusline is the branch name alone.

Otherwise no new tests: the refactor runs under existing coverage (107
statusline tests: 57 unit, 50 integration), with the A/B above as the
end-to-end evidence, since the tests alone only pin what each surface
does today and wouldn't catch the two plans silently diverging.

> _This was written by Claude Code on behalf of max_
2026-07-24 17:51:49 -07:00
Maximilian Roos a5ca8a169f refactor(perf): build every wt-perf fixture branch through one primitive (#3584)
Follow-up to #3548, which consolidated the `wt-perf` prune fixtures but
left the branch *builders* diverged.

## The refactor

Three generators had each grown their own way to create a fixture
branch. `add_history_spread_branches` pointed a ref at an old commit,
`add_diverged_backdrop` hand-rolled ~45 lines of index plumbing, and
`create_mixed_repo_at` used `checkout -b` plus a return-to-main for two
of its four states.

They are the same operation. A branch's state is entirely the pair
**(fork point, own-commit count)**: zero commits at an old fork is
"behind" and at the tip is "identical"; a positive count from the tip is
"ahead" and from anywhere else is two-sided "diverged".
`add_branch_with_commits` takes that pair, and the mixed fixture's
four-state rotation collapses to a table:

```rust
let (fork, commits) = match i % 4 {
    0 => (checkpoint, 0),                // behind
    1 => (base_tip.as_str(), 1 + i % 3), // ahead
    2 => (checkpoint, 1 + i % 3),        // diverged
    _ => (base_tip.as_str(), 0),         // identical to the tip
};
```

The checkout path is what actually mattered. `git checkout` of an old
fork point on rust-lang/rust rewrites the whole tree (minutes per
branch), which is why `add_diverged_backdrop` grew plumbing in the first
place. Routing the mixed fixture through the same primitive removes the
last checkout from fixture construction, so the main worktree is never
touched and the fixture is no longer structurally barred from a
rust-scale repo.

The win is structural. Fixture build time is unchanged: `mixed-8-8` runs
3.25/3.28/3.71s after against 3.33/3.55/3.91s before, and `mixed-24-120`
14.9/15.7s against 15.5/23.5s. That is parity within noise, and the
before-side spread swamps the difference.

## The guards

These generators had nothing pinning their output. The benchmarks
measure one wall-clock number, so a generator change that collapsed
(say) "diverged" into "ahead" would keep every bench green while
silently measuring a different repo. Three tests now assert the shapes
directly, via `merge-base --is-ancestor` exit codes and porcelain
status:

- the `full` fixture's documented `index % 4` rotation of branch and
worktree states
- the same fixture's zero-dimension contract (`mixed-W-0` /
`mixed-0-B`), which holds only because `0..0` never enters the loop that
divides by `branches`
- `add_diverged_backdrop`'s own wiring: every member unintegrated, fork
depths fanning across history, worktrees dirty only via untracked
scratch, and populations that don't follow each other

Each was mutation-tested rather than trusted for being green. A clamped
divisor gives `left: 1, right: 0`, and a fork spread collapsed onto the
tip gives `fork depths must fan out across history: [0, 0, 0, 0]`.

## One thing to look at

Writing the guards turned up a docstring overclaim in two places.
`history_spread_shas` samples newest-first, so index 0 is the default
branch's own tip. That makes `add_diverged_backdrop`'s "the default
branch has advanced past every fork" and `add_history_spread_branches`'s
"behind-only" both false as written. The first is true in its only
caller, where `add_squash_merged` advances the default branch on the
very next line.

I corrected the docstrings rather than the sampling, and pinned the
behavior with `assert_eq!(depths[0], 0, "the newest sample is main's own
tip")`. Changing the sampling would shift the workload of every prune
and `list` benchmark that uses the spread, moving Criterion's historical
series with no rename to signal it, in exchange for one branch out of 24
to 120 gaining fork depth. Happy to flip it if you'd rather; it is
documented and asserted either way.

## Verification

`hook pre-merge` green (4542 tests), and `cargo bench --bench list full`
runs end to end against the rebuilt fixture. Shape verified at benchmark
scale rather than only at the test's `N = 8`: a real `mixed-24-120`
holds the rotation through `br-0118`. The refactor is content-identical
for the backdrop (same blob content, filenames, commit messages,
plumbing sequence) and structurally identical for the mixed fixture
(same fork points and commit counts, since `0..=(i%3)` and `1 + i%3`
agree).

> _This was written by Claude Code on behalf of max_
2026-07-24 17:46:22 -07:00
Maximilian Roos a0c1b662d2 perf(fsmonitor): resolve all daemons in one lsof spawn (#3581)
## Why

`wt remove`'s end-of-command fsmonitor sweep forked one `lsof` per
*machine-wide* `git fsmonitor--daemon` — and with `core.fsmonitor`
enabled globally, a machine accumulates one daemon per repo ever touched
(100+ is routine). So every `wt remove` paid ~100 process spawns. The
cost is superlinear under load, not linear: a fork storm makes macOS
Gatekeeper/XProtect assess each new image under contention, inflating
per-spawn cost for everything else on the box, sweeps included. The
`~50ms each` figure in the old docstring was measured *under that load*
and read as a fixed per-call cost — so the sweep looked merely slow
rather than self-amplifying. Two concurrent test suites driving hundreds
of `wt remove` calls pinned all 18 cores (load average 142) with nothing
hot in `htop`.

This was diagnosed and measured before (#2814 added the sweep; #3401
wrote the cost into the docstring and explicitly changed no behavior)
but never fixed.

## The fix

Resolve every daemon in a single `lsof -a -p <pid1,pid2,…> -U -F pn`
call and split its output on the `p<pid>` record boundary, handing each
PID exactly the output the per-PID call produced — so classification and
the whole data-safety contract in the module docs are unchanged.
Verified end-to-end against a live 108-daemon machine with a PATH shim
counting invocations: **108 spawns → 1**.

Two details the batched path must preserve, each covered by a new test:

- **Don't gate on `lsof`'s exit status.** It exits non-zero when *any*
requested PID vanished mid-scan, while still printing full records for
every survivor. Gating would drop the whole sweep whenever one daemon
happened to exit — the common case on a busy machine.
- **A PID with no record is dropped**, never synthesized as a
socket-less entry. A socket-less entry reads as orphan class 1 and gets
SIGTERM/SIGKILL — on a possibly-recycled PID.

## Also here

- **Test consolidation.** Three integration fixtures built their branch
set by spawning git per loop iteration. A new
`TestRepo::create_branches` creates them in one `git update-ref
--stdin`. The over-threshold completion test drops **10.5s → 1.4s**
(~230 git spawns → 5); `switch_picker` and `statusline` branch loops get
the same treatment. `mock-stub` gains an opt-in `MOCK_CALL_LOG_DIR` call
log so a test can assert *spawn count*, not just response — deliberately
outside the repo under test, since a log written inside the tree would
dirty it mid-`wt merge`.
- **Docs.** Dropped the "disable `core.fsmonitor` globally" workaround
from the troubleshooting docs (canonical `skills/`, mirror regenerated).
It stays enabled by choice, and the daemons were never the actual
fseventsd CPU driver.

- **CI hardening.** pre-commit.ci's `typos` hook maps the token
`pn`→`on` and had auto-rewritten `lsof -F pn` to the broken `lsof -F on`
(offset+name — no `p<pid>` records, so the parser reaps nothing). Pinned
`pn` in `.typos.toml` next to the existing `PN`-from-`PNGs` entry that
documents the same mapping, and added a test covering the empty-PID-set
early return (flagged by `codecov/patch`).

## Testing

New unit tests cover `daemons_from_batched_lsof` against real captured
`lsof -F pn` output, a vanished-PID gap, and empty output; the rewritten
integration test asserts exactly one `lsof` spawn with a comma-joined
PID list. Full integration suite green (1960 passed), clippy and fmt
clean.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-24 17:27:36 -07:00
Maximilian Roos bd3ebaffde Release v0.69.1 (#3577)
Release v0.69.1.

A patch release: the Windows code-signing upload fix (`archive: false`,
so SignPath receives the real zip rather than a zip-of-a-zip), crates.io
publishing via trusted publishing (OIDC), a `wt switch` picker
correctness fix after a deleted-CWD recovery, and a statusline
dev-server URL dim. See CHANGELOG.md for the full list.

> _This was written by Claude Code on behalf of max_
v0.69.1
2026-07-24 17:15:12 -07:00
Maximilian Roos bb43011c8d fix(picker): cancel background commands when the picker exits (#3560)
## What

`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.

Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.

Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.

## Why

Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.

Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:

| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |

Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).

## Why a latch, not just a sweep

A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.

Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.

This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.

## Notes

- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
  the picker stays live, so pressing Enter straight after can leave a
  `git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
  and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
  removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
  each call site.

- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
  an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
  back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
  still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
  test is deterministic (a file marker for readiness, not a sleep).

An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.

Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.

Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
  which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
  are pre-existing: the `spawn_removal` refactor re-indents the dispatch
  closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
  judged worth more than the metric.

- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
  by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00
Maximilian Roos f5d99d4fe5 fix(step): refuse to commit unresolved conflicts; one gutter symbol for every operation (#3579)
Two follow-ups from #3558, which stopped `wt merge` and `wt step rebase`
from running out of a half-finished operation. Probing the sibling
commands for the same root cause found one that was worse, and the
symbol work is what that PR's detection change made possible.

## `wt step squash` committed conflict markers

Mid-merge, invoked directly, it didn't refuse. It generated a commit
message for the unresolved markers and committed them:

```console
$ git merge side
CONFLICT (content): Merge conflict in f
$ wt step squash --yes
◎ Generating commit message and committing changes... (1 file, +6, no squashing needed)
  Merge branch 'side'

  This resolves a merge conflict between main and side branches.
✓ Committed changes @ e6262c6
$ git show HEAD:f
<<<<<<< HEAD
main
||||||| 56e6eb3
base
=======
side
>>>>>>> side
```

`MERGE_HEAD` is gone and the working tree is clean, so the broken merge
reads as complete. `wt merge` was never exposed — its own gate stops it
before it reaches squash — so this was the direct-invocation path only.

## The index is what knows

The obvious fix is the gate #3558 added, and it is not sufficient. The
hazard is narrower than "an operation is open", and also wider.

`git add -A` collapses an unmerged path's three index stages into one
entry. That resolves the conflict as far as the index is concerned,
while `<<<<<<<` is still on disk — and it takes with it git's own
refusal to commit an unmerged index, which is what would otherwise have
stopped this. So the exposure is exactly the commands that stage on the
user's behalf, and it does not need an operation to be open at all:

```console
$ git stash pop
CONFLICT (content): Merge conflict in f
$ ls .git/MERGE_HEAD
ls: .git/MERGE_HEAD: No such file or directory
$ wt step commit --yes
✓ Committed changes @ 1f5387c        # markers and all
```

A conflicted `git stash pop` leaves unmerged paths with no state file
written, so no reading of `.git/` can see it.
`WorkingTree::ensure_no_unmerged_paths` reads the index instead — `git
diff --diff-filter=U`, the same question `git commit` asks — and both
staging paths call it before staging:

```console
$ wt step commit
✗ Cannot commit: 1 path with unresolved conflicts
   ┃ f
```

The paths are worth carrying where the operation refusal carried
nothing: they are the one thing the user needs and git isn't being
asked.

`wt step squash` additionally takes the operation gate, ahead of its
branch check, so mid-rebase it names the open rebase rather than blaming
the detached HEAD and offering `git switch <branch>` — the one command
that throws the rebase away, which is the same wrong remedy #3558
removed from `wt merge`.

Both guards run before the pre-commit hooks and the LLM call. Neither is
worth running for a commit that can't happen, and a refused commit runs
no project commands — checked with a `pre-commit = "touch HOOK_RAN"`
project config that never fires.

`wt step commit --dry-run` is deliberately not gated. It mutates
nothing, and guarding it displaced
`test_step_commit_dry_run_propagates_git_add_failure`, which pins a
distinct error path; a tested behavior is worth more than cosmetic
parity.

## One symbol for every operation

The Status column had `⤴` for rebase and `⤵` for merge, and nothing for
the other three states git can leave open. A worktree stopped
mid-cherry-pick, mid-revert, or mid-bisect rendered as idle — including
the case #3558 closed, a multi-commit cherry-pick whose stop was
resolved with `git commit`, which leaves a clean tree, no
`CHERRY_PICK_HEAD`, and only the queued sequencer to say anything is
wrong.

Three more glyphs was the obvious fix and the wrong one. What the reader
does about any of the five is identical: run `git status`, then finish
or abort it. Splitting the column across a glyph per operation asked
them to distinguish states that lead to the same next step, and the
split is what left the other three invisible. So they collapse to one:

```console
$ wt list
  Branch  Status  …  Message
@ main       ↻^   …  resolved by hand
```

`git status` names which operation it is, in git's own words — the same
division of labor as the refusal message. `--format=json` keeps the
identity the symbol drops, so a consumer that needs it still has it:
`operation_state` gains `cherry_pick`, `revert`, and `bisect` alongside
`rebase` and `merge`.

That retires `ActiveGitOperation`, which existed only to re-encode
`InProgressOperation` down to the two states the gutter knew.
`WorktreeData.git_operation` is now
`Option<Option<InProgressOperation>>` — outer `None` is "not loaded" —
matching its neighbour `has_working_tree_conflicts`. `GitOperationTask`
also stops swallowing errors: it reports a failed probe through the same
`ctx.error` channel every other task uses, rather than reporting "no
operation" for a probe that didn't answer.

## Verification

Three integration tests, each asserting HEAD is unmoved rather than only
matching the message:

- `test_step_squash_refuses_mid_merge` — the case that committed
markers.
- `test_step_commit_refuses_unmerged_paths` — driven from a conflicted
`git stash pop`, so it can only pass through the index read; the
operation check cannot see that state.
- `test_list_shows_symbol_for_bisect` — bisect because it is the only
operation that leaves HEAD on the branch and the tree clean, so nothing
else in the Status cell stands in for it. Snapshots pin both the symbol
and `"operation_state": "bisect"`.

Confirmed by hand against real repos: mid-merge, mid-rebase, and the
conflicted-stash-pop state all refuse; a mid-merge commit whose
conflicts *are* resolved still succeeds, as does a clean squash; a
queued cherry-pick and a stopped revert both render `↻` and name
themselves in JSON.

Full gate green (4500/4500 tests, lints, fmt, doc sync), plus `cargo
test --features shell-integration-tests` (2148/2148) and `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 16:33:49 -07:00
Maximilian Roos a07cf60be9 docs(claude): stop coverage from vetoing a better design (#3580)
## The failure this fixes

A session working on the picker found a structural improvement: routing
both removal dispatches through a `spawn_removal` helper, so the
uninterruptible-cancellation exemption lives in one place instead of
being remembered at each spawn site. It declined to make the change,
because the helper re-indents the morph closure's body and pulls two
pre-existing uncovered lines (a failure-path warn and a `revert_morph`
call) into the patch. Projected `codecov/patch`: ~97.3% against an
`auto` target of ~97.8%. Red. The improvement was shelved.

The section it was reading said "NEVER merge a PR with failing
`codecov/patch` without explicit user approval," then "on failure,
investigate and fix the gap," then "if you believe it's a false
positive, ask." Every route treats a red patch as a defect in the
change. The cheapest way to satisfy all three is to not make the change.

## What changed

The Coverage section now separates the two decisions it was conflating.
The check gates the merge; it never gates the design. A predicted red
patch is not a reason to shelve or water down an improvement, and
neither the code nor the tests get contorted to move the number. The
merge rule is unchanged: still never merge red without approval. What is
new is the route through it when the gap isn't real (misses that predate
the change, or a path with no deterministic trigger): push the change
and ask, handing over which lines, why they can't be covered, and what
the patch percentage comes to.

Two smaller corrections ride along:

- "Remove unused code" to green the number now excludes a rarely-reached
backstop that's load-bearing. The picker lines in question guard a
TOCTOU window during rename-based removal, and the repo's Data Safety
rules would rather keep them.
- `tests/CLAUDE.md`'s note on rename false positives now covers the
mechanism that caused this. Codecov counts any line the diff relocates
as a patch line, re-indentation included, not just a `git mv`.

The `spawn_removal` refactor itself is not in this PR: it touches picker
code mid-flight on #3560.

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 16:18:37 -07:00
Maximilian Roos edcfe5fe0c fix: honour -C in the statusline and in every supplied path (#3567)
`-C` sets the repository discovery path but never chdir's the process,
so anything reading `std::env::current_dir()` silently ignores it. Two
places did.

## `wt list statusline` described the wrong worktree

`wt -C /path/to/other/worktree list statusline` printed the statusline
for the *cwd's* worktree, and from a directory outside any repo it
printed nothing at all.

Both formats now resolve their worktree through
`Repository::current_worktree()`, which anchors to the discovery path.
That accessor already exists for this bug class (its docstring cites
#2625), so this is routing the statusline onto the canonical path rather
than adding a new one.

Details worth knowing while reading the diff:

- `--format=json` was mostly right already. It picked its worktree via
`current_worktree()` and only its "are we in a worktree" gate read the
cwd, so it misbehaved just when the process cwd sat outside a repo
(printed `[]`). That gate moves to `current_worktree()` and the `cwd`
binding is gone.
- `git_status_segments` now takes a `WorkingTree` instead of
`(&Repository, &Path)`. That also settles a split inside it: the
worktree lookup used the cwd while the no-worktree fallback used
`repo.current_worktree()`.
- `--format=claude-code` is unchanged where it matters. The directory
Claude Code sends on stdin still wins, since that names the session's
directory rather than wt's. With no stdin it falls back to the same
worktree as the other formats, so it honours `-C` too.

Four integration tests. Three run from the main worktree with `-C`
pointed at a `feature` worktree, one per format; the existing statusline
tests all set the child's cwd, which is the route that already worked.
The fourth pins the claude-code split: when stdin names a directory in a
*different* repository, the worktree comes from stdin while
`list_worktrees` comes from wt's discovery path, so nothing matches and
only the branch is reported. That fallback previously had no test, which
is what `codecov/patch` caught on the first push.

Discovering the `Repository` from the stdin directory instead would
remove that split, but it would miss the `prewarm` cache (keyed on the
base path) that this prompt-redraw hot path depends on, so the split
stays and is now asserted rather than incidental.

## Supplied paths resolved from the shell, not from `-C`

`wt switch ../repo.feature` and `wt remove ../repo.feature` joined the
argument onto the process cwd. With `-C` in play that split the
invocation in two — wt chose the repository, the shell chose the path —
and the combination resolved to nothing, so `switch` reported "No branch
named ../repo.feature". `--config`, `WORKTRUNK_CONFIG_PATH`,
`WORKTRUNK_SYSTEM_CONFIG_PATH`, and the trace file of `wt config state
logs profile` name a file the same way and had the same gap: `wt -C repo
--config mycfg.toml config show` reported "Config file not found" for a
file sitting in `repo/`.

Git resolves the paths in its command line against its own `-C`, because
it chdir's there before doing anything else. wt's `-C` sets a discovery
path instead, so `resolve_input_path` joins onto that — one resolution
point for all five, so they cannot drift apart. It returns the path
untouched when `-C` was not given, rather than joining `.` onto it: the
process cwd already resolves those, and the join would surface as a
stray `./` in output. `Path::join` leaves an absolute path alone, which
drops an `is_absolute` branch and, in `resolve.rs`, a fallible
`current_dir()` call.

Three neighbours are deliberately outside the rule, and the docstring
says why:

- Paths wt derives itself — a worktree root from `git worktree list`, an
XDG config directory — arrive absolute.
- `WORKTRUNK_PROJECT_CONFIG_PATH` resolves from the worktree root, its
documented base, because the project config is per-worktree.
- `WORKTRUNK_BIN` and `XDG_CONFIG_DIRS` are never opened by wt: the
first is expanded later by the generated shell wrapper, and the XDG spec
already requires the second to be absolute.

Four integration tests, one per surface, each running from *outside* the
repo with `-C` pointed into it — the neighbouring tests run from a cwd
inside the repo, which is the route that works under either rule. All
four fail against the old code: `switch` with "No branch named
../repo.feature-detached", `remove` by leaving the worktree standing,
and the two config paths by not finding their file.

The `set_base_path` docstring picks up the rule, so the next reader has
one place to find it: `-C` is the base for the two things git takes from
its chdir — the repository discovered, and every path the user names —
while the process cwd stays the answer to where the user's shell
physically is.

## Noticed, not touched

`run_json` and `git_status_segments` duplicate the
worktree-lookup-and-populate sequence (`list_worktrees` → find by root →
`is_home` → `build_worktree_item` → `populate_item` with identical
`CollectOptions`). Worth collapsing separately.

> _This was written by Claude Code on behalf of max_
2026-07-24 15:57:23 -07:00
Maximilian Roos f9cd38eb88 fix(shell): validate command names and tighten install/uninstall detection (#2864)
Two shell-integration correctness fixes plus an uninstall-scope
redesign.

**Command-name validation.** The shell-integration command name comes
from the user-typed `--cmd` flag, or `argv[0]` when `--cmd` is omitted.
A malformed value (an empty string, a leading `-`, or characters like
`;` or whitespace) renders into a broken shell rc line that the user
then has to find and fix. `validate_shell_command_name()` rejects empty,
leading-`-`, and non-`[A-Za-z0-9._-]` names at the CLI entry points, so
a bad value produces a clear error up front instead of a silently broken
config file. This is input hygiene on a value the user already controls,
not a security boundary.

**Noncanonical-line detection on install.** Install recognized only its
exact current canonical line, so a manually-added or older-form
integration line (e.g. `eval "$(wt config shell init zsh)"`) was not
seen as already-present and a duplicate was appended on the next
`install`. It now detects those forms and reports already-configured.

**Uninstall scans every worktrunk-managed file, regardless of cmd.**
Previously `uninstall` located Fish/Nushell wrapper files by name
(`{cmd}.fish` / `{cmd}.nu`), so removing integration installed under an
alternate binary name needed a matching `--cmd`. That symmetry leaked an
implementation detail: uninstall already has a content marker to
recognize worktrunk wrappers regardless of binary name. It now scans the
wrapper directories (`~/.config/fish/functions`, `conf.d`; the nushell
candidates' `vendor/autoload`) and admits every file whose content
matches the cmd-agnostic marker `# worktrunk shell integration for
<shell>` (always emitted by the wrapper templates). A headerless file
(the legacy `conf.d/{cmd}.fish`, which held nothing but the init line)
qualifies only when every non-blank, non-comment line is an integration
line, so a user's own file that runs or merely mentions `wt config shell
init` amid other code survives the scan. Fish completion files carry
their own `# worktrunk completions for` header and are scanned the same
way, so a completion whose wrapper is already gone is still cleaned up.

For Bash/Zsh/PowerShell (line-based),
`is_shell_integration_line_for_uninstall_any_cmd` reads the binary name
off the line rather than pattern-matching it. `config shell init` is a
fixed literal, so the name is the run of command-name characters ending
at it, held to the same `validate_shell_command_name` rule that governs
what worktrunk writes into shell code. The execution-context check that
pairs with it is a line-level property, so it lifts out of the
per-position loop it used to sit in, which is what let both cmd-agnostic
detectors drop their regexes. Since the result asks only whether a line
is worktrunk's, not which install owns it, uninstall also removes
hand-written `git wt config shell init` lines, and it cleans every
matching profile file rather than the first (both PowerShell profiles on
Windows). All detection reads only the code portion of a line: a
trailing `#` comment can mention an integration line without running it,
so uninstall does not delete an unrelated `eval`/`source` line over a
comment, and install does not treat such a mention as
already-configured. The cmd-specific permissive detector lost its last
caller in this cutover and is deleted.

The `--cmd` flag is removed from `wt config shell uninstall`; `install`
keeps it (installing under an alternate name is still a deliberate
per-cmd act). The FAQ's "What can Worktrunk delete?" inventory documents
the widened uninstall surface.

Covered by new integration tests: custom-cmd install + cmd-less
uninstall round-trips for zsh/fish/nushell (verifying scan-all removes
the custom-cmd integration, a hand-written `git wt` line, a stale second
wrapper, and an orphaned completion), a PowerShell uninstall round-trip
on the old pre-`Out-String` line, malformed-name rejection at the CLI
edge (`--cmd` and `argv[0]`), and the older-line dedup case. Unit tests
pin the deletion criterion: worktrunk's own wrappers and the headerless
legacy init file match; a file that mentions the integration, or runs it
amid other code, does not.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Worktrunk Bot <w@worktrunk.dev>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-07-24 09:32:17 -07:00
Maximilian Roos 4549715af1 docs(claude-code): link the statusline segment details instead of restating them (#3576)
The `## Statusline` section of `claude-code.md` paraphrased three facts
that the `wt list statusline` reference already owns:

| Fact | `claude-code.md` said | `src/cli/list.rs` says |
|---|---|---|
| Pace maths | "1.4× the pace that would exactly fill that window,
coloured by how much of it would be spent locked out at the cap" | "2.9×
the pace that would exactly fill that window… colour deepens with
severity as the forecast lockout grows" |
| Link degradation | "clickable links where the terminal supports them,
plain text where it doesn't" | "Both links are OSC 8, which a terminal
that doesn't support them discards" |
| CI fetch latency | "runs it in the background… 1–2 second CI fetch
invisible" | "reach the network for a second or two, so it fits a
statusline the host renders in the background" |

#3568 removed the near-verbatim copies from this section but left these
paraphrases behind, which is arguably the worse state: two wordings of
one fact drift apart independently, and a reader can't tell whether
they're the same claim or two different ones.

## What stays

The cut isn't to zero. The page shows an example line, so it needs
enough gloss for a reader to parse the line in front of them — a bare
pointer would make the section a stub. It keeps one sentence naming what
the stdin JSON contributes, with "how the links behave" folded into the
pointer that was already there.

The latency clause also stays, though it is the third duplicated fact.
Without it the opening paragraph only restates the command name, and it
is the one "why" that justifies the `settings.json` block below it.

## Side effect

This leaves the `OSC 8` wording in `src/cli/list.rs` as the single home
for the link behaviour. A precise term earns its place in an exhaustive
`--help` reference and grated on an end-user integration page — so the
jargon was really a symptom of the fact living in two places.

Docs-only. Mirrors regenerated by `test_docs_are_in_sync`; `zola build`
clean (14 pages, anchors resolve).

> _This was written by Claude Code on behalf of Maximilian Roos_

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:29:51 -07:00
Maximilian Roos 11a1498fa1 docs(list): render wt list statusline's help on the docs site (#3568)
`wt list statusline`'s `after_long_help` documents the three output
formats, the Claude Code stdin JSON contract, and the pace segment, but
`wt list`'s help had no `<!-- subdoc: statusline -->` placeholder, so
none of it reached `docs/content/list.md` or the skill reference. It was
terminal-only.

Adding the placeholder pulls it in as a `wt list statusline` section
under `# Subcommands`, matching how `wt step` and `wt config` expose
theirs.

## What reading it on the web surfaced

The help text needed edits once it rendered as a page rather than a
block below an Options list. A context-blind agent was given the two
rendered pages and asked to answer setup questions from them alone;
three of its snags were real.

**The format lists were stale and used private names.** They read
`branch status ±working commits upstream ci url`, while the Columns
table higher up the same page calls those `HEAD±`, `main↕`, `Remote⇅`.
They also omitted `main…±`, which `format_statusline_segments` has
emitted since that column became a default. The lists now use the column
names and include it, and `claude-code` is stated as a delta on `table`
rather than repeating it.

**The formats read as a fixed layout.** The example line on the Claude
Code page has nine segments against an eleven-name format string, with
no explanation. Three rules were in the code and in no doc: empty cells
are omitted, `claude-code` drops `branch` when `dir` already ends in
`.<branch>` (`filter_redundant_branch`), and an overlong line drops
whole cells worst-priority-first (`fit_to_width`). All three are now
stated.

**The latency caveat lived only on the Claude Code page.** A reader of
the command's own docs had no way to learn it reaches the network. It
moves to the reference. That exposed a contradiction with the
definition, "Single-line status for shell prompts", against a caveat
saying it is too slow for a synchronous prompt — so the definition
becomes "Single-line status for the current worktree". This is the one
user-visible string change here; it lands in `wt list --help` and the
three help snapshots.

## Deduplication with the Claude Code page

The pace paragraph and the OSC 8 paragraph were near-verbatim on both
pages, and would have rendered twice on the site. `claude-code.md` keeps
what is Claude Code-side (install, demo, the example line, and a plain
note that the links degrade to unclickable text where the terminal lacks
support) and links to the reference for the rest.

## Follow-ups folded in

The module docstring at `src/commands/statusline.rs:4` carried `±working
commits upstream`, the last occurrence in the tree of the labels this
branch retired, and opened "Statusline output for shell prompts" — the
framing the definition dropped. Both now match the help text.

## Not done

An audit of every `after_long_help` in `src/cli/` found 46 that never
reach a docs page. Most are editorial calls rather than oversights: `wt
config create` alone would embed ~450 lines of example TOML into a page
that already covers that ground by hand. The one that looks like a plain
oversight is `wt step rebase` / `wt step push`, the only two of twelve
step operations without a marker, and the only two the `## Operations`
list leaves unlinked. Covering them needs their openers rewritten first,
since both currently restate their definition. Left for a follow-up.

Verified with `wt hook pre-merge --yes` (4499 tests) and a `zola build`,
which checks internal anchors.

> _This was written by Claude Code on behalf of Maximilian Roos_

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 07:45:25 -07:00
Maximilian Roos 84cbb0489e fix(step): refuse to rebase or merge from a worktree mid-operation (#3558)
## The bug

With a rebase in progress (a conflicted stop, or a killed `git`), `wt
step rebase main` reported success over a conflicted tree:

```console
$ git rebase main          # stops on a conflict; HEAD detached mid-replay
$ wt step rebase main
○ Already up to date with main
$ echo $?
0
```

`handle_rebase` asked `is_rebased_onto` first. Mid-rebase, HEAD sits on
a linear extension of the target, so that returns true and the function
returns `UpToDate` before anything consults `worktree_state`.

`wt merge` from the same state failed differently but no better:

```console
✗ Cannot merge: not on a branch (detached HEAD)
↳ To switch to a branch, run git switch <branch>
```

The hint names the one command that throws the rebase away instead of
finishing it.

## The fix

Both commit-replaying commands now check for an open operation before
doing anything else. `wt merge` runs the check ahead of its branch
check, so the open operation is what gets named rather than the detached
HEAD it causes.

```console
$ wt step rebase main
✗ Cannot rebase: a git operation is already in progress
```

The gate covers every state detection reports, not just rebase. Measured
on the others beforehand: a rebase started mid-bisect ran and stacked a
second operation on the first, and one started mid-merge or
mid-cherry-pick died inside git's autostash (`Cannot save the current
index state`, `f: needs merge`). None told the user what to do next.

## Why it prescribes no remedy

An earlier draft of this branch carried a per-operation table of
`--continue`/`--abort` hints. It was wrong on first contact with the
long tail: `git am` borrows the `rebase-apply` directory, so an open am
session was reported as a rebase and pointed at `git rebase --continue`,
which git refuses outright:

```console
$ git rebase --continue
fatal: It looks like 'git am' is in progress. Cannot rebase.
```

Adding an `Am` arm would have treated the enumeration as the fixable
part. `git status` already answers this better than the table did, in
git's own words and translations, for states worktrunk has no variant
for:

```console
$ git status
interactive rebase in progress; onto b162044
You are currently rebasing branch 'feat' on 'b162044'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)
```

It names the operation, the branch, the progress, and every way out
including `--skip`, which the table never offered. So the table is gone
rather than extended, and the refusal is a single line: it says what is
blocked and why, and leaves the rest to the command every git user
reaches for next.

Detection still returns a structured `InProgressOperation` rather than a
display string, but now purely as detection: the only caller that reads
a variant is the one classifying a rebase worktrunk itself just started.

The neighbouring `RebaseConflict` error took the same treatment. It
forwards git's stderr in the gutter when it has it, and otherwise
substituted two hint lines of its own:

```console
↳ To continue after resolving conflicts, run git rebase --continue
↳ To abort, run git rebase --abort
```

That is the operation table again in miniature — one operation's
remedies, minus `--skip` — and the one path that reaches it is the
safety check for a rebase git *reported as finished*, where worktrunk
knows least about what is actually open. It now prints its title, plus
git's own words when git said any.

## One detector

Two places probed the same files for the same answer:
`Repository::worktree_state` for the gate, and
`WorkingTree::is_rebasing`/`is_merging` for the `wt list` gutter
symbols. They are now one method, `WorkingTree::operation_in_progress`,
which `Repository::operation_in_progress` delegates to — the rename also
retires a name that collided with `wt list`'s unrelated `WorktreeState`.

Detection now runs in the order `git status` consults the state files,
which is a change from the old `wt list` path: it checked rebase before
merge. Nothing can reach the difference — no rebase backend writes
`MERGE_HEAD`, checked against all three — but matching git's order is
one less thing to reason about. `wt list` maps the operations without a
gutter symbol (cherry-pick, revert, bisect) to `None`, so its output is
unchanged.

## The queued sequencer, now closed

Having one detector is what made the last gap worth closing. `git
cherry-pick A B` that stops on A writes `CHERRY_PICK_HEAD`; resolving
that stop with `git commit` instead of `git cherry-pick --continue`
removes the file and leaves B queued:

```console
$ git status
On branch main
Cherry-pick currently in progress.
  (run "git cherry-pick --continue" to continue)
$ ls .git/CHERRY_PICK_HEAD
ls: .git/CHERRY_PICK_HEAD: No such file or directory
$ cat .git/sequencer/todo
pick 1f07533 side1
pick 6b32540 side2
```

Git reads that same file to keep calling the sequence in progress, so
detection reads it too: the first instruction word, `pick` or `revert`,
exactly as git's own `sequencer_get_last_command` does.

The reason this waited for its own change is the risk of refusing a `wt
merge` the user can't un-refuse, so the lifecycle is measured rather
than assumed. The directory is gone once the sequence finishes, and gone
after `--quit` or `--abort`; a single-commit cherry-pick never creates
one; a rebase keeps its todo elsewhere, under `rebase-merge/`. An empty
or unrecognized todo reports nothing, so the failure direction is the
old behavior rather than a refusal with no way out.

## Verification

The repro above, plus the merge, am, cherry-pick, revert, and bisect
states, checked by hand against real repos: all five refuse, including
am, which is now correct precisely because nothing here claims to know
what am needs.

Three integration tests drive the real binary from a genuinely stopped
operation (`test_step_rebase_refuses_mid_rebase`,
`test_merge_refuses_mid_rebase`, `test_merge_refuses_mid_merge`).
`test_merge_refuses_mid_merge` is what holds the broadened scope: a
conflicted merge keeps HEAD on the branch, so it is the case the
detached-HEAD check waves through. Narrowing the gate back to
rebase-only turns it red, which I verified by making that edit and
re-running.

The sequencer case is driven end to end from a real stopped pick —
`test_operation_in_progress_reads_the_queued_sequencer` commits the
resolution by hand, asserts `CHERRY_PICK_HEAD` is gone, and still
expects a cherry-pick, so it can only pass through the sequencer read.
It then runs `git cherry-pick --quit` and expects the state to clear,
which is the false-refusal half. Running the same sequence against the
built binary refuses where it previously reported `○ Already up to date
with main`.

Confirmed unchanged: already-up-to-date, fast-forward, true-rebase, and
rebase-conflict output; a plain detached HEAD still gets the `git
switch` hint, which is the right remedy when no operation is open; a
sibling worktree mid-rebase does not block a clean one; `wt list`'s
gutter symbols.

Full gate green (4494/4494 tests, lints, fmt), plus `cargo clippy
--all-targets --features shell-integration-tests`, which the gate
doesn't compile.

## Merged with main

`main` gained #3539, which extracted the failed-rebase classification
into `classify_rebase_failure`, and #3549, which adds `span_upstream` —
both inside `handle_rebase`. The conflict was semantic as well as
textual: #3539's `is_rebasing` check still read the old string API
(`worktree_state()?.is_some_and(|s| s.starts_with("REBASING"))`), which
this branch removes, so resolving the markers alone left a type error.
The resolution keeps #3539's classification and reads the structured
enum instead:

```rust
let state = repo.operation_in_progress()?;
let is_rebasing = matches!(state, Some(InProgressOperation::Rebase));
return Err(classify_rebase_failure(e, is_rebasing, &integration_target));
```

All five of #3539's classification tests pass alongside the gate.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 07:39:28 -07:00
Maximilian Roos 7b741d0791 fix(release): stop double-zipping the SignPath upload artifact (#3566)
## Summary
- `actions/upload-artifact` wraps every upload in its own GitHub storage
zip unless `archive: false`. The uploaded file here is already a zip
(`worktrunk-x86_64-pc-windows-msvc.zip`), so the default produced a
zip-of-a-zip — SignPath treated the outer storage wrapper as "the
artifact" and the configured `<pe-file path="wt.exe">` never matched
anything inside it.
- Root-caused this against the real v0.69.0 release run: the SignPath
dashboard shows the failed request's unsigned artifact as
`unsigned-windows-zip.zip` (16.8MB, matching GitHub's storage-wrapper
name, not the real filename) with error `Expected path to match exactly
1 item, but found 0` for `wt.exe`.
- `archive: false` uploads the file as-is (single-file only, which is
what we have), so SignPath receives the real zip directly, one level
shallower — matching the artifact configuration as originally written.
- Also fixes the v0.69.0 CHANGELOG's SignPath entry, which read "Signed
with a test certificate" — overclaiming, since the actual signing
attempt on that release failed. Reworded to describe the pipeline's
intent rather than a specific run's result.

v0.69.0 shipped with an unsigned Windows binary as a result (signing
failure was masked by the intentional `continue-on-error`, so the
release itself succeeded — this is exactly the non-blocking behavior
that was designed in). This fix should make the next release's signing
attempt succeed for real.

## Test plan
- [x] `actionlint .github/workflows/release.yaml` — no new warnings
- [x] Root cause confirmed directly against the SignPath dashboard's
error details and artifact tab for the failed v0.69.0 signing request
- [x] Verified `archive: false` semantics against
`actions/upload-artifact@v7`'s own `action.yml` (single-file upload,
uploaded as-is)

> _This was written by Claude Code on behalf of Max_
2026-07-23 19:30:19 -07:00
Maximilian Roos f87e678aa1 fix(switch): trim redundant Repository rebuilds on the accept path (#3557)
## Summary

Follow-up to #3544's `wt switch` review, from investigating why the
accept path re-forks `git config --list -z` and rebuilds `Repository`
more than necessary.

- Skip the destination-rooted `Repository::at()` in
`spawn_switch_background_hooks` when the approved hook plan is empty
(the common no-project-hooks case) — `HookAnnouncer::flush()` is already
a no-op there, so this changes no behavior, only cost.
- Fix the picker's `is_recovered` accept-path arm, which reused the
startup-time `Repository` unconditionally. An in-picker alt-x/alt-r
during a recovered session can mutate the worktree/branch inventory, and
that arm never rebuilt to see it — the non-recovered arm already gets
this via a fresh `Repository::current()`. Rebuild via `Repository::at`
(mirrors the picker's own `rebuild_repo` idiom) instead of
`Repository::current()`, which fails after a deleted-CWD recovery.
- Replace a misleading comment ("reuse the recovered repo") with the
actual freshness rationale.

A fourth change — sharing the bulk config cache across same-process
`Repository` instances by `git_common_dir` — was implemented and then
reverted: the pre-merge gate's
`test_primary_remote_honours_checkout_default_remote` caught a real
staleness bug (a config mutation between two `Repository::at()` calls,
e.g. from a switch hook, would go unobserved by a cache hit with no
invalidation path). Measured idle-repo cost of the remaining duplicated
config forks is small (~5-10ms each), and the risk of a bespoke
process-wide cache with no write-invalidation wasn't worth it.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (full test + lint suite): 4483
tests passed
- [x] `cargo clippy --all-targets --features shell-integration-tests --
-D warnings`: clean
- [x] Targeted tests: `git::repository::`, `picker::`,
`worktree::switch::`, `hook_plan::`

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:50:04 -07:00
Maximilian Roos 481125801f ci: publish to crates.io via trusted publishing (#3564)
## What

`publish-cargo` now mints its crates.io credential per run through
`rust-lang/crates-io-auth-action` (GitHub Actions OIDC) instead of
reading
the `CARGO_REGISTRY_TOKEN` environment secret. The job gains
`id-token: write` for the OIDC exchange and `contents: read` for
checkout,
narrowing it from the workflow-level `contents: write`.

The token lives for about 30 minutes and the action's post step revokes
it,
so no long-lived publish credential exists anywhere.

## Why

The stored token expires on a schedule, and each expiry is a silent
trap:
nothing breaks until the next release tag, which is exactly when you
don't
want to discover it.

## Prerequisite, already in place

A Trusted Publisher is configured for `worktrunk` on crates.io —
repository `max-sixty/worktrunk`, workflow `release.yaml`, environment
`release`. It had to land before this merge, since otherwise
`publish-cargo` would fail at the auth step on the next release tag.

`CARGO_REGISTRY_TOKEN` can be deleted from the `release` environment
once a release has gone out this way. `AUR_SSH_PRIVATE_KEY` stays.

> _This was written by Claude Code on behalf of max_
2026-07-23 18:46:12 -07:00
Maximilian Roos 166e1190ba refactor(perf): canonicalize wt-perf prune-fixture layering + completion config (#3548)
## What

Three behavior-preserving canonicalizations of the `wt-perf` benchmark
fixtures, from a design pass on "why does prune need its own generator,
and can the fixtures consolidate?"

1. **Share the prune-population layer.** `create_prune_repo_at`
(synthetic) and `create_prune_real_repo_at` (rust-scale) both layered
the same two populations onto their base repo — `add_diverged_backdrop`
(the scanned-but-never-removed backdrop) + `add_squash_merged` (the
removable candidates). Extracted into `add_prune_populations`, so the
base repo (synthetic `create_repo_at` vs. rust clone) is now the *only*
thing the two fixtures differ in, spelled out once instead of twice.

2. **Canonicalize `completion.rs` onto `RepoConfig::branches`.** Its two
inline `RepoConfig` literals were exactly `RepoConfig::branches(50, 0)`
(one with `worktrees` overridden), so they now use the canonical preset
via struct-update instead of respelling all seven fields.

3. **Document the fixture → bench mapping.** `benches/CLAUDE.md` listed
the `wt-perf setup` handles in one section and the bench groups in
another with nothing connecting them, so a bench and its fixture (`full`
and `mixed`) read as overlapping names for the same thing. Added a
mapping table and the rule behind it.

## Why this is the whole safe consolidation

The design question was whether prune could drop its bespoke generator
for a "standard one with different params" — ideally one diverse repo
serving prune *and* the other benches. The answer is that prune's
generator is **already** a thin composition (`create_repo_at` + two
additive layers), and it can't fold into the shared `mixed`/`full`
fixture without breaking three prune-specific invariants: a
known/cleanly-separated candidate count (the destructive `live` path
re-creates exactly the consumed candidates), untracked-only dirt (the
`git reset --mixed` index-heal depends on it — `mixed`'s staged-dirt
states violate it), and a provably-unintegrated backdrop. And benchmark
fixtures optimize for *attribution*, not realism: the uniform
single-axis fixtures (`worktree_scaling`, etc.) exist precisely because
the diverse `full` fixture can't localize a regression, so diversifying
them would lose that.

On the `full`/`mixed` naming specifically: they can't be canonicalized
to one name, because fixtures and benches are **many-to-one** —
`RepoConfig::typical` alone backs five bench groups (`skeleton`,
`worktree_scaling`, `first_output`, `picker_preview`, `remove_e2e`), two
of which sit in the same file measuring different things. `full`/`mixed`
only looks 1:1 by coincidence. Renaming also isn't free:
`.github/scripts/criterion-to-jsonl.py` keys each time-series row by the
criterion output path (`<group>/<id>`) for the daily gist append, so a
rename orphans that series. Documented rather than renamed.

## Proposed follow-up (not in this PR)

The one remaining real duplication is two *implementations* of
"two-sided-diverged population forked across history depth":
`add_diverged_backdrop` (plumbing-based, scales to rust) vs. the
diverged branch states inside `create_mixed_repo` (checkout-based,
synthetic-only). They can't naively merge — the checkout path would make
`prune-real` take minutes per branch — but a shared plumbing primitive
could back both. That rewrites a carefully-tuned fixture feeding the
`full` bench with **no shape-assertion test to catch drift**, so I'd
land a fixture-shape test first (count refs by state + assert
integration status). Happy to do it if wanted.

## Verification

fmt `--check` clean; `clippy -p wt-perf --all-targets` and `clippy
--bench completion` clean; `pre-commit` clean on the doc; `cargo test -p
wt-perf` passes (incl. the prune-fixture tests
`squash_merged_fixture_is_content_integrated` /
`prune_fixture_state_classifies_lifecycle`, which exercise
`create_prune_repo_at` → `add_prune_populations`); all benches build.
Table contents verified against the code by grep.

> _This was written by Claude Code on behalf of Maximilian Roos_

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:40:00 -07:00
Maximilian Roos f3e5c82604 feat(statusline): dim the dev-server URL until its port answers (#3561)
Follow-ups to #3550. The dev-server URL in the statusline dims until
something answers on its port, matching the `wt list` cell it was
already copying in every other respect.

```
 ~/w/worktrunk.feature  ↕|🤖  ↑2 ↓1  #3550  :17913     server up
 ~/w/worktrunk          ⊂|    ↑2 ↓1  #3550  :12107     server down (dim)
```

`url_active` was already collected on this path (the health check runs
under the statusline's full-column plan), so the dim consumes something
already paid for rather than adding a probe.

## The other half, and why it isn't here

The follow-up list also carried "detect OSC 8 support and render
accordingly", which is how `wt list` decides its URL cell: linked
`:3000` when the terminal supports hyperlinks, the URL in full when it
doesn't. I built that, and three review passes took it apart. Recording
the path, because the conclusion is the interesting part:

1. The first version probed `stderr`, since a shell prompt captures
stdout through command substitution, and forced links on for
`--format=claude-code`. An adversarial pass showed the exception
reintroduced the bug it was meant to fix: Claude Code re-emits OSC 8 to
the terminal rather than drawing it, so Claude Code inside Apple
Terminal got the link, and the URL collapsed to a bare `:3000` with its
host inside an escape the terminal discarded.
2. Reading the environment alone fixed that and deleted the exception.
Then a bug sweep measured what the plain-text fallback actually costs:
unlinked, the cell grows from 5 columns to the whole URL, and the URL is
the worst-priority segment, so `fit_to_width` drops it first. Against a
long dev hostname the linked line kept the URL down to 39 columns and
the unlinked one lost it below 82. At 80 columns in tmux the "fallback"
showed nothing where the old code showed a clickable port.
3. Separating the two decisions fixed that (collapse to `:port` always,
link only when the terminal renders it) but left the probe controlling
nothing but escape bytes. At which point an outer-loop pass asked
whether it should exist, and it shouldn't:

- The [OSC 8
spec](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
guarantees a terminal implementing OSC parsing per ECMA-48 "is
guaranteed not to suffer from compatibility issues … the target URI is
silently ignored and the supposed-to-be-visible text is displayed,
without artifacts." The named buggy set is VTE up to 0.48 (2018),
Windows Terminal up to 0.9, Emacs term-mode, and screen with 700+
character URLs. The statusline has emitted unconditional OSC 8 to shell
prompts since #199 in January, on that same reasoning.
- `supports-hyperlinks` is an allowlist, so its dominant error is the
false negative. xterm, foot, mintty, rio, contour and a configured tmux
all render OSC 8 and none are named. The probe's certain cost is
withdrawing a working link from those users, against a speculative and
shrinking risk.
- The escape-hatch argument I had for it was inert. Once the probe no
longer changed any text, `FORCE_HYPERLINK=0` could not reproduce what
the old hardcoded Claude Code gate did, which was print the URL in full
and copyable during that regression window.

So the answer to "shouldn't we detect?" is that `wt list` detects
because there the answer changes what the reader gets:
`estimate_url_width` reserves a column wide enough for the full URL, so
an unlinked cell can print it. The statusline reserves nothing, so it
has no second rendering to switch to. `format_url_cell` now says that,
in place of a stale note claiming the statusline's stdout was a pipe "to
its editor".

## Also here

- `isolate_subprocess_env` scrubs `FORCE_HYPERLINK`. It stands alone:
`wt list` probes with `on(stream)`, which is `(FORCE_HYPERLINK set ||
tty) && allowlist`, and tests pin `TERM=alacritty`, so a developer with
`FORCE_HYPERLINK=1` exported flips the `wt list` table snapshots from
full URL to linked `:port`.
- The `table` and `claude-code` format lists in `wt list statusline
--help` name the `url` segment, which they emit and never listed.
- A `--format=claude-code` test pins where the URL segment lands among
the directory, CI and model segments.

## Verification

Full gate green (`cargo run -- hook pre-merge --yes`, 4486 tests).
Beyond the suite, the built binary rendering a live and a dead port, and
the drop threshold measured at six widths to confirm the segment behaves
as before.

## Known, not addressed

A `[list] url` with no port is permanently dim on both surfaces:
`UrlStatusTask` only connects when a port parses, so `url_active` stays
`None`, and the `== Some(true)` test reads "never checked" as "nothing
listening". Pre-existing in `render.rs`, which this change doesn't
touch.

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

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:38:10 -07:00
Maximilian Roos e2b9563701 docs: TODO deferred merge-stats split and squash-preview coverage (#3563)
## Summary

Follow-ups from #3549 (the stale-local-ref fix, fixing #3519), recorded
as inline TODOs rather than a separate tracking issue:

- **`src/commands/worktree/push.rs`**: `MergeContext::prepare`'s
`commit_count` mixes carried fast-forwarded upstream commits with the
branch's own squash commit when the target was behind its upstream, so
the merge success line ("Merged to main (N commits, ...)") overstates
the branch's own contribution. Splitting it needs a carried-count
threaded through `MergeContext` — deferred as cosmetic.
- **`src/commands/step/squash.rs`**: `preview_squash` (backing
`--dry-run`/`--show-prompt`) shares the upstream-measured base with
`handle_squash`, but only the mutating path has a test asserting it
(`test_step_squash_measures_against_upstream_when_local_main_stale`). A
snapshot test pinning the preview output in the same stale topology
would close this gap.

No behavior changes — comments only.

## Test plan

- [x] `cargo check`
- [x] `cargo clippy --all-targets`
- [x] `cargo fmt --check`

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

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:36:47 -07:00
Maximilian Roos c1c70a7e39 docs(changelog): fold #3549 and #3539 into the 0.69.0 changelog (#3562)
> _This was written by Claude Code on behalf of max_
v0.69.0
2026-07-23 18:25:30 -07:00
Maximilian Roos 7b2e0705a1 Release v0.69.0 (#3559)
Highlights: clickable CI and dev-server-URL statusline segments in Claude Code (#3550), `wt merge --no-rebase` accepting merge-shaped fast-forward histories (#3509), a faster and lower-CPU `wt switch` picker (#3544, #3534), and SignPath code signing for the Windows release binary (#3553). Full details in CHANGELOG.md.

> _This was written by Claude Code on behalf of max_
2026-07-23 18:08:29 -07:00