Commit Graph

33 Commits

Author SHA1 Message Date
Maximilian Roos f57fac365b Release v0.72.0 (#3759)
Release v0.72.0 — 55 commits since v0.71.0.

Minor bump: `cargo semver-checks` reports 5 breaking library changes, so
patch is disallowed pre-1.0.

## Headline changes

- **`wt merge` / `wt step push` no longer autostash the target
worktree** (#3703). Both strategies now advance the target through one
`advance_target` — a compare-and-swap `update-ref`, then `read-tree -m
-u` in the target worktree — so `refs/stash` is never entered and staged
changes stay staged.
- **Forge classification returns to brand-in-hostname** (#3673),
reverting the exact-DNS-label rule 0.71.0 shipped.
`github-enterprise.acme.com` and friends resolve again with no config.
- **`[projects."…"]` keys match by `*` pattern and carry forge
settings** (#3701), so one user-config entry covers every repository on
a self-hosted host.
- **A published JSON Schema for `wt list --format=json` schema 2**
(#3747), plus machine-readable approval state and `branch_outcome`
(#3710).

Full detail in `CHANGELOG.md`.

## One fix made during the release cut

The release audit surfaced a gap this release's own `advance_target`
rewrite introduced, fixed here rather than deferred:

**`wt merge` / `wt step push` now refuse a target worktree parked
mid-operation.** The target sync is a two-tree merge, which refuses an
unmerged index but *not* a stopped cherry-pick or rebase whose conflict
has already been staged. A target paused between steps could therefore
have the push range written into it, and the user's `--continue` would
commit the synced tree as the step's result. The old fast-forward path
got this check for free from `receive.denyCurrentBranch=updateInstead`,
which refused any unclean target outright; both strategies now ask
directly, and the refusal names the worktree holding the operation.

`test_push_refuses_target_mid_operation` covers it in both shapes a
stopped operation can take, and both are mutation-verified. With the
gate disabled, the push succeeds and writes `feature.txt` into the
mid-cherry-pick worktree.

The rebase case was added in response to review feedback on this PR, and
pins a second dependency. A rebase detaches HEAD, so `git worktree list
--porcelain` reports the target with no branch and `worktree_for_branch`
finds it only because `finalize_worktree` backfills from
`rebase-merge/head-name`. That makes the rebase arm the one place this
guarantee rests on a helper of ours rather than on git — the
fast-forward path it replaced got the refusal from `find_shared_symref`.
With the backfill disabled, `wt step push` succeeds against a worktree
parked mid-rebase while the cherry-pick case still passes, so the gap
was real.

## Validation

- Local gate green: `cargo run -- hook pre-merge --yes` — 4570 tests,
clippy, fmt, doc sync.
- Cross-platform nightly green on the cut-from tip `3817df079` (run
31133551751): full nextest matrix on linux/macOS/Windows,
feature-powerset, all three release triples, nix-flake,
minimal-versions, unused-deps, crate-build, link-check.
- Changelog verified entry-by-entry against the diffs by an independent
pass; every one of the 55 commits either maps to an entry or is a
documented skip.
- `main` advanced during the CI wait. #3762 ships in this release and
now has a changelog entry; the other two commits that landed (#3758,
#3749) touch only `.github/`.
- Data-loss surface reviewed by four independent finders over the
cumulative diff. One further finding — a pre-0.72 `approvals.toml` key
containing `*` being reinterpreted as a wildcard on upgrade — was
reviewed and accepted as out of scope for this release.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 02:22:28 -07:00
Maximilian Roos 4a6fd1ec44 refactor(merge): leave target-worktree changes in place, drop the autostash (#3703)
`wt merge` / `wt step push` previously moved a dirty target worktree's
uncommitted changes aside with an autostash (`git stash push -u`) and
restored them after the push. That design entered `refs/stash` — a
repo-global namespace any process can mutate, the source of #3683's race
class — restored staged changes as unstaged, and existed only because
the fast-forward's `receive.denyCurrentBranch=updateInstead` refuses any
dirty worktree at all.

Both strategies now advance the target through one `advance_target`:

- a compare-and-swap `update-ref` (fails cleanly if the target moved
since the snapshot; reflog entries are labeled),
- `update-index -q --refresh` + `read-tree -m -u <old> <new>` in the
target worktree — git's documented lenient `push-to-checkout` policy
(githooks(5)),
- a CAS rollback when the sync can't apply, so branch and worktree move
together or not at all.

Uncommitted changes at paths the push doesn't touch never move: unstaged
edits stay unstaged, staged entries stay staged, untracked files stay
put, and `refs/stash` is never involved. The autostash machinery
(`TargetWorktreeStash`, `StashData`, `stash_restore_failed` and its
exit-code path from #3693) is deleted — including the
staged-restored-as-unstaged flaw, which disappears with the restore
itself.

Behavior changes:

- Receive hooks no longer fire on the fast-forward path — there is no
`git push`. A `git merge` run in the target wouldn't run them either,
which is the line the module spec draws.
- A sync that can't apply fails the whole command with the ref rolled
back; `--no-ff` previously warned and left the worktree stale behind its
own branch.
- An untracked-path collision is refused upfront, naming the file,
instead of stashed and later maybe-conflicting.

Review highlights (three adversarial passes over the diff):

- The upfront conflict check reads `status --porcelain -uall`, so
untracked files inside untracked directories get the named upfront
refusal rather than a generic sync failure (and one subprocess is
dropped).
- A commit racing into the CAS→sync window is detected by a post-sync
ref re-read — receive-pack used to give the fast-forward path this check
structurally — and warned about.
- `read-tree` runs under `-c submodule.recurse=false`, keeping #1604
fixed for `submodule.recurse=true` users.
- The ignored-file carve-out (an ignored file at a path the push tracks
is overwritten, as a `git merge` there would) is pinned by an end-to-end
test: two review passes claimed `read-tree` refuses it; experiment on
git 2.55 refuted both.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 23:10:55 -07:00
Maximilian Roos 03f49ce93a fix(merge): exit non-zero when the target autostash can't be restored (#3693)
Follows #3684. The Windows test un-gating that was stacked here is split
out into #3695, now merged; this PR is the exit-code change alone.

## Problem

`wt merge` reported success when the target worktree's autostash failed
to replay. The warning named the recovery command, but exit 0 said the
user's uncommitted changes were back in their worktree while they were
still in a stash — and that warning scrolls past under the
worktree-removal and post-merge-hook output that follows it.

## Solution

The restore outcome travels out through `PushResult`. The command
finishes everything it started — ref advanced, worktree removed, hooks
run, `--format=json` payload printed — and only then returns
`AlreadyDisplayed { exit_code: 1 }`.

Aborting at the restore instead would leave a landed merge with its
cleanup half-done, trading a recoverable stash for a worse mess. The
shell wrapper applies its `cd` directive whenever the directive file is
non-empty, independent of exit code, so a non-zero exit strands nobody
in a removed directory.

Both output channels name the failure: the `--format=json` payloads of
`wt merge` and `wt step push` carry `stash_restore_failed`, present on
every payload like the other outcome booleans. The exit code alone would
leave a consumer reading stdout with a success-shaped object and no
signal.

This also closes a gap the change surfaced: `handle_no_ff_merge`'s
already-up-to-date early return never called `restore_stash`, so a dirty
target worktree with nothing to merge restored through `Drop` and
reported nothing. It restores on that path too now, which is what makes
the guarantee hold — every remaining `Drop` of the guard happens on a
path already returning an error.

## On diverging from git

`git rebase --autostash` exits 0 in this situation: it prints "applying
them resulted in conflicts" and still reports "Successfully rebased".
The difference is what the user is left looking at. git's failure leaves
conflict markers in the working tree, met immediately; a failed `git
stash apply` here can leave the worktree untouched — an untracked path
re-created underneath it, for instance — so nothing but the exit code
outlives the warning. The reason is recorded on the field the exit code
hangs off, so it doesn't read later as an oversight.

## Testing

`test_merge_autostash_restore_failure_exits_non_zero_after_cleanup`
covers the guarantee end to end: exit 1, `stash_restore_failed` in the
JSON, ref advanced, source worktree removed, stash entry still present
for recovery. `test_push_autostash_restore_failure_warns` moves from
asserting `success()` to asserting exit 1 plus the push having landed.

Also verified against a real build outside the suite, on both commands:
the merge lands, the worktree is cleaned up, exit is 1, the JSON reports
`"stash_restore_failed": true`, and the warning names the exact `git
stash apply <sha>`.

`wt step push --help` gained the failure contract, since it previously
described only the success path; the generated mirrors are regenerated
with it.

Local (macOS): `cargo run -- hook pre-merge --yes` green — 4500 tests,
clippy, fmt, doc sync.

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:13:39 -07:00
Maximilian Roos 8a2a2f92f6 test: run the git-hook autostash tests on every platform (#3695)
Split out of #3693 so it can be reviewed on its own. Follows #3684.

## Problem

The three tests that install a native git hook —
`test_push_autostash_survives_concurrent_stash`,
`test_push_autostash_restore_failure_warns`, and
`test_merge_target_diverges_during_receive_restores_autostash` — were
gated `#[cfg(unix)]`, so the autostash regression they guard went
unverified on Windows.

Nothing they assert is unix-specific; git runs hooks through a shell it
ships on every platform. The gate was carrying two incidental blockers:

- `std::os::unix::fs::PermissionsExt`, for an executable bit Windows has
none of.
- A `root.display()` path interpolated into the hook script, whose
Windows backslashes would reach `sh` as escapes.

## Solution

`common::write_git_hook` writes the hook and gates only the chmod, so
the platform fact lives in one place instead of being re-derived per
test. The repo root is interpolated with `path_slash`'s `to_slash_lossy`
— the form `step_prune.rs` already uses to embed a path in a hook
command that runs on Windows.

## Why un-gating is safe

Each test asserts that its own hook fired: the push tests require
`INTERLOPER` in the stash list, and the merge test requires the target
ref to have advanced. A hook that silently fails to run on Windows
therefore fails the test rather than passing vacuously.

Confirmed rather than assumed — on the stacked branch these tests ran
green on `test (windows)`, and pulling that job's junit artifact shows
all three present by name, so they executed rather than being filtered
out.

## Sweep

The rest of the suite's platform gates were checked and left alone; the
remaining ones are gated for real reasons: symlinks, signal delivery,
unix permission bits, `lsof`/fsmonitor daemon reaping, shell-script
`git` shims on `PATH` (Windows can't exec a shebang script that way),
ConPTY output capture, and clap's differing `[experimental]` tag
rendering in Windows snapshots (`step_alias.rs`, `help.rs` — already
documented in-place). The `#[ignore]`s and elevated-privileges runtime
skips are likewise documented and intentional.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-01 11:51:22 -07:00
Worktrunk Bot 194edd5ea2 fix(merge): restore autostash by commit SHA, not stash@{0} (#3684) 2026-08-01 09:44:21 -07:00
Maximilian Roos 6d09125b7b test: converge suite on semantic boundaries (#3663)
This follows the first test-simplification tranche by converging the
remaining suite around distinct semantic and pragmatic contracts rather
than raw case count. The branch removes false-confidence tests, invalid
setup variants, repetitive snapshots, and expensive PTY overlap while
strengthening the retained route, precondition, and interaction proofs.

## What changed

- Replace obsolete CI-status integration mocks and blank snapshots with
direct provider semantics, mixed-priority cases, and strict
GitHub/GitLab route assertions.
- Remove free-riding merge, push, remove, list, security, config, and
switch cases whose setup never reached the named behavior; consolidate
repetitive direct cases into labeled tables.
- Reduce the switch picker from 42 PTYs to 19 distinct terminal
contracts, using causal release gates for asynchronous loading and
repaint behavior.
- Add a cached main-only picker fixture, eliminating 138 unnecessary Git
subprocesses across the retained PTYs, and integrate it with main's
generated hermetic standard fixture.
- Tighten test guidance around proving setup preconditions and mock
invocation routes, and correct the comments-tab help text and generated
mirrors.

## Reviewer map

- `tests/integration_tests/ci_status.rs` and
`src/commands/list/ci_status/`: provider semantics and route coverage.
- `tests/integration_tests/switch_picker.rs`, `src/commands/picker/`,
and `src/testing/`: retained PTY contracts, causal mocks, and fixture
design.
- `tests/integration_tests/config_show.rs`, `src/config/deprecation.rs`,
`src/config/expansion.rs`, and worktree type/resolve tests:
direct-boundary consolidation.
- `tests/CLAUDE.md`: the testing rules extracted from the
false-confidence cases found during the survey.

The measured loop removed 98 tests, 65 snapshots, and 23 picker PTYs.
Controlled warm Nextest execution improved from a 79.593-second mean to
72.574 seconds (8.8%), while comparable production-line coverage moved
from 97.32% to 97.23%. The tracked PR diff is a net deletion of more
than 5,700 lines.

## Validation

- `cargo run -- hook pre-merge --yes` after syncing current `main`:
4,468 passed, one configured skip; docs, doctests, clippy, formatting,
policy checks, and snapshots green.
- `task coverage` on the completed change before the base sync: 4,465
passed, one configured skip; 97.23% comparable production-line coverage.
- Three independent final audits found no remaining lost beliefs,
fixture hazards, or safe PTY consolidations.

> _This was written by Claude Code on behalf of max_.
2026-07-30 00:01:32 -07:00
Maximilian Roos 14580de79c feat(worktree): accept a worktree path wherever a branch is accepted (#3607)
Follow-up to the [`wt remove <path>` discussion on
#3480](https://github.com/max-sixty/worktrunk/pull/3480#issuecomment-5039137116),
widened from that one command to the whole surface. #3480 has since
landed and is merged in here — its duplicate-checkout warning composes
with this: the warning names the shadowed worktrees, and a path is how
you then address one.

## Audit

Verified against the built binary. wt had three answers to "what does
this token mean?":

| Route | `@` `-` `^` | worktree path | `pr:N` |
|---|---|---|---|
| `wt switch` (`resolve_switch_target`) | yes | only if absolute or ≥2
components, and not `--create` | yes |
| `wt remove` (`resolve_worktree_arg`) | yes | any token | no |
| everything else (raw `worktree_for_branch`) | **no** | **no** | no |

Same token, same cwd, two answers:

```console
$ wt remove inner     # ✓ Removed innerbranch worktree & branch
$ wt switch inner     # ✗ No branch named inner
```

And outside switch/remove the shortcuts didn't work at all — `wt step
diff --branch @` was `✗ Branch @ has no worktree`, while `wt config
state marker set --branch @` silently wrote state under the literal key
`@`. Separately, wt prints paths as `~/…` but wouldn't accept that form
back.

## Change

One canonicalizer in the lib, `Repository::resolve_worktree`, absorbing
the path fallback that lived in the bin crate's `resolve_worktree_arg`
(now deleted). Resolution order is documented once, on that function:
`@`, then `-`/`^`, then a branch with a worktree, then a path naming a
registered worktree, then the branch alone.

**Branch-first, everywhere.** A directory never shadows a branch that
shares its name; a path answers only what a branch cannot — a detached
worktree, or one of two checkouts of the same branch (#3480's case). The
`looks_like_path` shape gate is gone, so a single-component path
resolves like any other.

Two shapes cover what callers need: `require_worktree` for commands that
need a worktree to operate in, `require_selected_branch` for arguments
that key by branch. The merge/rebase target validators fall through to
the same path lookup, so a target can be named by the worktree it's
checked out in.

Routed through it: `switch` (including `--base`), `remove`, `step commit
--branch`, `step diff --branch` and its target, `step copy-ignored
--from`/`--to`, `step promote`, `step relocate`, `config state --branch`
(9 sites), and `merge` / `step rebase` / `step squash` / `step push`
targets.

`resolve_input_path` — already documented as the one resolution point
for user-supplied paths — now expands a leading `~`, so the tilde form
worktrunk prints is a form it reads back. `~user` stays literal; wt
doesn't reimplement that shell feature.

## Documentation

A path is an alias, not a second addressing scheme, so it is stated once
rather than on every argument: one paragraph in `wt switch`'s help and
one sentence on the addressing line in `worktrunk.md`. Argument
descriptions still read as branches. The two exceptions are the
arguments whose descriptions are already catalogues of accepted forms —
`wt switch`'s (`Branch, worktree path, shortcut, or PR/MR URL`) and `wt
remove`'s, which has named the path since before this branch. The
Worktree Model section of `CLAUDE.md` records which way to document it,
so the next argument doesn't grow its own copy.

## Two silent no-ops fixed along the way

- `wt step relocate <unmatched>` matched arguments against branch names
by string equality, so a typo filtered everything out and the empty
result rendered as `○ All worktrees are at expected paths` — a success
message for work that never happened. Every way an argument can fail to
land on a relocatable worktree now errors, including the detached and
prunable cases the new path route makes reachable.
- A selector matching nothing was reported as a branch without a
worktree, hinting `wt switch <token>` — which creates a worktree only
when the branch exists, so for a mistyped path it would just fail again.
`WorktreeSelectorNotFound` now says `No branch or worktree named X`; a
branch that genuinely exists without a checkout keeps the create hint.

## Testing

Full gate green: 4596 tests, lints, docs sync, `--features
shell-integration-tests` clippy. `codecov/patch` is 99.25% of diff hit
against a 97.93% target. New coverage:

- Unit: branch-and-path equivalence, branch-beats-same-named-directory,
detached-by-path (and its `require_selected_branch` refusal), shortcuts
never treated as paths, branch-only fallthrough, and the two distinct
not-found errors. Plus `expand_tilde` round-tripping
`format_path_for_display`.
- Integration: `switch` by relative/single-component/absolute/tilde
path, `--base` by path, `step diff --branch` by path and `@` (asserted
equal to the by-branch output), `config state --branch` set via `@` and
read via the worktree path, and both new relocate errors.

`wt remove`'s resolution is unchanged — it already had this rule; it now
shares the implementation. The 106-test `remove::` suite is untouched
and green.

- Integration: `wt step push <worktree-path>` (the
`require_target_branch` half of the target fallback), and `wt step
relocate` against a prunable worktree.

One diff line is unhit: `expand_tilde`'s fallback when `home_dir()`
returns `None`, which has no deterministic trigger. The `@`-resolution
backstop in `resolve_worktree` is untested for the same reason — no CLI
route reaches it — so it kept its original `match` arm rather than being
re-indented into the diff.

## Left out

`wt config state default-branch set` and `previous-branch set` take a
branch name as a *value to store* rather than a selector, so they still
take it literally.

> _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 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:10:46 -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
Maximilian Roos ddf550a21c refactor(styling): return Option<usize> from terminal_width() (#3043)
`terminal_width()` signaled "can't detect width" by returning
`usize::MAX`, with a doc-comment contract that callers handle the
sentinel. Three consumers each re-implemented the check, and two misses
shipped as user-visible bugs fixed in #3040 (`wt list --help` panicked
with a capacity overflow when piped; piped diffstats truncated filenames
to ~10 chars).

The function now returns `Option<usize>`, so mishandling the no-width
case is a compile error rather than a convention. Help rendering and
`show_diffstat` pass the `Option` through naturally; the statusline
parent-TTY walk triggers on `None` instead of a sentinel comparison.
Consumers that genuinely want an unlimited width (statusline budget,
picker split math, list layout, gutter wrapping) now say
`.unwrap_or(usize::MAX)` explicitly at the use site, where the choice is
visible. The gutter formatters keep calling the wrap pipeline with that
unlimited width rather than skipping it, since `wrap_ansi`
post-processing affects output bytes even when nothing wraps.

No behavior change: output is byte-identical in every width context,
with zero snapshot or generated-doc churn, and the #3040 regression
tests (`test_help_without_detectable_width`,
`test_push_diffstat_without_detectable_width`) pass unchanged.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:13:20 -07:00
Maximilian Roos 4c2aeba95f refactor(cli): dedupe the config state --format flag; fix width-sentinel bugs (#3040)
The identical 10-line global `--format` declaration was repeated across
the five `wt config state` subcommands (`cache`, `logs`, `hints`,
`ci-status`, `marker`); this extracts it into a shared
`GlobalFormatFlag` args struct flattened into each, so the attributes
and help text can't drift apart. Help output is byte-identical: no
snapshot or generated-doc changes. Other repeated flag declarations were
reviewed and deliberately left inline: the switch/remove/merge
`--format` flags differ in their doc comments, and the remaining
identical pairs (two `--dry-run`s, two text-format and two table-format
flags) are too small to be worth the indirection of a wrapper struct.

Review of the change surfaced two pre-existing mishandlings of
`terminal_width()`'s `usize::MAX` "width unknown" sentinel, both fixed
here with regression tests:

- `wt list --help` panicked with a capacity overflow when neither stdout
nor stderr is a TTY and `COLUMNS` is unset (e.g. `wt list --help >
file`): the help renderer received the sentinel as a real width and
tried to render a `---` rule that wide. The sentinel now maps to the
renderer's `None`.
- The post-commit diffstat passed the sentinel to `git diff
--stat-width=`, making git truncate filenames to ~10 characters in piped
output; the flag is now omitted when no width is known.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:50:45 -07:00
Maximilian Roos a2482f1da9 refactor: change test git_command() to return Cmd instead of Command (#1718)
The test infrastructure's `git_command()` returned
`std::process::Command`, so test git commands bypassed `Cmd`'s debug
logging (`$ git status [ctx]`) and timing traces (`[wt-trace]`). This
changes it to return `worktrunk::shell_exec::Cmd`.

## Approach

Added `configure_git_env(Cmd, &Path) -> Cmd` alongside the existing
`configure_git_cmd(&mut Command)`. The `Command` version is kept because
`configure_wt_cmd` (which configures wt binary commands) still needs it
— wt commands go through `Command`, not `Cmd`.

Key changes in `tests/common/mod.rs`:
- `TestRepoBase::git_command()` and `TestRepo::git_command()` now return
`Cmd`
- All wrapper methods (`run_git`, `git_output`, `head_sha`, etc.) use
`.run()` instead of `.output()`
- `BareRepoTest::new()` and `NestedBareRepoTest::new()` use
`Cmd::new("git")` for init

247 call sites across 25 files converted: `.output()` → `.run()` and
`.status()` → `.run()` on git command chains. Zero `Command::new("git")`
remaining in the test directory.

Follows #1714 and #1716 which converted raw `Cmd::new("git")` and
`Command::new("git")` in test bodies to `repo.run_command()`.

> _This was written by Claude Code on behalf of maximilian_

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:23:23 -07:00
worktrunk-bot 344082f102 fix: disable submodule recursion in local push and reset (#1619)
## Summary

- Add `--recurse-submodules=no` to the local `git push` in `wt
merge`/`wt step push` to prevent `submodule.recurse=true` from
interfering with worktree-to-worktree ref updates
- Add regression tests for both fast-forward and no-ff push with
`submodule.recurse=true` config

The `reset --hard` fix from the original PR is no longer needed — #1623
replaced `reset --hard` with `read-tree -m -u`, which doesn't recurse
into submodules.

Closes #1604

## Test plan

- [x] New test `test_push_with_submodule_recurse_config` — verifies
fast-forward push succeeds with `submodule.recurse=true`
- [x] New test `test_push_no_ff_with_submodule_recurse_config` —
verifies no-ff push succeeds with `submodule.recurse=true`
- [x] All existing push tests still pass
- [x] Lints pass

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

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:44 -07:00
worktrunk-bot f43fca7f75 feat: add --no-ff flag to wt step push (#1587)
## Summary

- Adds `--no-ff` / `--ff` flags to `wt step push`, mirroring the
existing flags on `wt merge`
- When `--no-ff` is set, delegates to `handle_no_ff_merge()` to create a
merge commit on the target branch instead of fast-forwarding
- Enables manual step-by-step merge workflows: `wt step commit && wt
step rebase && wt step push --no-ff`

Closes #1579

## Test plan

- [x] New `test_push_no_ff` integration test verifies merge commit
creation (2 parents) and commit message
- [x] All existing push tests continue to pass
- [ ] CI passes on all platforms

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

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:46:22 -07:00
Maximilian Roos 4709409ccb Consolidate test git commands using helper methods (#339)
* Consolidate test git commands using helper methods

Migrate ~180 instances of manual `Command::new("git")` calls to use
TestRepo helper methods for better consistency and reduced boilerplate:

- `repo.run_git(&["..."])` - run in repo root, panic on failure
- `repo.run_git_in(&path, &["..."])` - run in specific dir, panic on failure
- `repo.git_command()` - get configured Command for output capture

Changes:
- Add `run_git_in()` to `TestRepoBase` trait so BareRepoTest and
  NestedBareRepoTest also get this method
- Migrate tests in switch.rs, remove.rs, merge.rs, list.rs, completion.rs,
  shell_wrapper.rs, push.rs, security.rs, list_config.rs, list_column_alignment.rs
- Migrate internal TestRepo methods to use git_command() with chained env vars
- Remove unused `use std::process::Command` imports where no longer needed

Remaining intentional uses of Command::new("git"):
- TestRepoBase::git_command() and TestRepo::git_command() definitions
- BareRepoTest constructors for `git init --bare` (can't use git_command
  because init takes path as argument, not current_dir)
- Standalone bare repo test in remove.rs (to be refactored separately)

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Move BareRepoTest to common module and refactor bare repo test

- Move `BareRepoTest` from bare_repository.rs to common/mod.rs for reuse
- Add `wt_command()` helper method to BareRepoTest
- Refactor `test_remove_default_branch_no_tautology` to use shared BareRepoTest
- Remove unused imports (canonicalize, Command, TempDir) from remove.rs

This reduces the standalone bare repo test from 70+ lines to ~15 lines.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Move commit_in method to TestRepoBase trait

Consolidate BareRepoTest::commit_in_worktree into the shared
TestRepoBase trait as commit_in(). This allows all test harnesses
to share this method without duplication.

- Add commit_in() to TestRepoBase trait (tests/common/mod.rs:619)
- Remove duplicate commit_in_worktree from BareRepoTest
- Update 16 callers in bare_repository.rs
- Update 1 caller in remove.rs

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-31 15:46:01 -08:00
Maximilian Roos d13eb0c1d2 refactor(tests): simplify test helpers and remove unnecessary worktrees (#326)
- Add `check_git_status()` helper to verify git command success
- Add thin wrappers `run_git`, `run_git_in`, `git_output` using check_git_status
- Simplify `repo_with_main_worktree` fixture to no-op (primary is already on main)
- Remove unnecessary `add_main_worktree()` calls from merge tests and fixtures
- Fix latent test bugs where git commands were silently failing
- Use `set_temp_home_env` helper in post_start_commands tests
- Fix test using non-existent "develop" branch
- Fix test needing `repo_with_remote` fixture

Net reduction: ~208 lines

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-31 01:25:45 -08:00
Maximilian Roos 2052bdd84c Remove --allow-merge-commits flag, allow merge commits by default (#297)
The push command previously rejected history containing merge commits
unless --allow-merge-commits was passed. Since the default workflow now
squashes commits, this check is unnecessary complexity. Merge commits
are now allowed by default.

Removed:
- --allow-merge-commits CLI flag from wt step push
- allow_merge_commits parameter from handle_push()
- has_merge_commits() method from Repository
- GitError::MergeCommitsFound error variant

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-26 15:13:53 -08:00
Maximilian Roos 58fd181622 Refactor git status parsing to use NUL-separated output
Handle filenames with spaces and newlines by using git's -z flag for
NUL-separated output in status, diff, and file listing commands. Update
parsing logic for AutoStageWarning and changed_files to handle the new
format, and add regression test for overlap detection with renamed files.
2025-12-14 11:15:30 -08:00
Maximilian Roos 430a8aa331 Add test fixtures and refactor test infrastructure
Expand test fixtures with repo_with_remote_and_feature, repo_with_alternate_primary,
and repo_with_multi_commit_feature. Add TestRepo::add_feature() helper method and
refactor help tests to use rstest parametrization. Update test files to use new
fixtures, reducing boilerplate setup code.
2025-12-11 13:19:32 -08:00
Maximilian Roos e8d99854ec Add helper methods to TestRepo for common test patterns
This refactors repetitive test setup code by introducing three new TestRepo methods:
- `add_worktree_with_commit()`: Creates a worktree, writes a file, stages and commits it
- `commit_in_worktree()`: Adds a commit to an existing worktree (for multiple commits)
- `create_branch()`: Creates a local branch without checking it out
- `push_branch()`: Pushes a branch to origin

Also converts test functions from manual `#[test]` to use `#[rstest]` fixtures
with the `repo` and `mut repo` fixtures, eliminating boilerplate `TestRepo::new()`
calls and improving test consistency.
2025-12-11 02:47:23 -08:00
Maximilian Roos 1a8fb80d87 Add fixture-based test repo initialization for faster tests
Replace `git init` with pre-initialized fixture template to save ~10ms per test.
The fixture at tests/fixtures/template-repo/ contains a minimal git repo with
one deterministic commit (hash 23e5a15, timestamp 2025-01-01T00:00:00Z).

Changes:
- TestRepo::new() now copies fixture instead of running git init
- TestRepo::empty() added for tests needing uninitialized repos
- Removed ~320 redundant `repo.commit("Initial commit")` calls from tests
- Reduced fixture from 32 files to 9 (removed sample hooks, logs, etc.)
- Fixed path canonicalization in TestRepo::empty() for Windows compatibility
- Added typos exception for commit hash substring 'ede'

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 23:33:02 -08:00
Maximilian Roos fe6f424091 Simplify git config setup: include user identity in gitconfig file
The git config file now includes user name, email, and default branch
settings. This eliminates separate `git config user.name/email` commands
and removes the need to explicitly set the default branch with `-b main`
during `git init`, making test setup more concise and efficient.

Additionally, remove unnecessary `repo.setup_remote("main")` calls from
94 tests that don't actually require a remote repository. These tests
work fine with local branches only, and the remote setup was adding
unnecessary overhead during test initialization.
2025-12-10 13:40:51 -08:00
Maximilian Roos 56db53d984 Feat: Format TOML arrays as multiline for improved readability 2025-11-26 15:34:07 -08:00
Maximilian Roos 6fca4f7baa feat: Show HEAD SHA when no commits are pushed
When `wt push` is run and there are no commits to push, the success message now includes the short SHA of the current HEAD. This provides more context for the user.

Also changes the subcommand from `beta` to `step` for the `wt push` tests.
2025-11-23 23:13:53 -08:00
Maximilian Roos 7a05a54aaa Refactor: Replace expect with unwrap in tests
Replaces most uses of `.expect(...)` with `.unwrap()` in test code. This is a common pattern in Rust tests for operations that are expected to succeed, simplifying the code.
2025-11-23 21:00:31 -08:00
Maximilian Roos 646779cc26 feat(push): Stash target worktree changes during push 2025-11-06 11:28:42 -08:00
Maximilian Roos 41a632ec33 feat: rename dev command to beta and update snapshots 2025-11-05 10:00:49 -08:00
Maximilian Roos 178cb6e85b refactor: Remove all platform-specific conditional skips from tests (#6)
* Refactor switch command to infer default branch

Adds support for creating a new worktree/branch without specifying a
base branch. If no base is specified, the command now attempts to
resolve the repository's default branch. This makes the `--create`
behavior more convenient for typical new branch workflows.

* feat: Improve CLI messaging for merge and push commands

The merge command now provides a more explicit confirmation message when a worktree is preserved with the `--no-remove` flag, replacing the previous hint about using `wt remove`.

For the push command, a new note is added to the progress output to acknowledge when the `--allow-merge-commits` flag is used and merge commits are present. This enhances clarity about why a typically disallowed operation was permitted.

* feat: Define list ordering rules

Adds specific ordering to the `list` command:
1. Main worktree first.
2. Current worktree second (if not main).
3. Remaining worktrees by most recent commit timestamp.

A new integration test `test_list_ordering_rules` is included to verify these rules.

* refactor: Remove all platform-specific conditional skips from tests

Removes defensive programming patterns that gracefully adapted to missing
dependencies or platform differences. Tests now fail fast with clear errors
instead of silently skipping, aligning with "trust boundaries" and "low
cardinality" principles (one code path per test, not multiple branches).

Changes:
- Remove 8 bash/fish availability checks (e2e_shell*.rs)
- Remove 7 Windows/Unix command syntax conditionals (switch.rs, configure_shell.rs)
- Remove 2 macOS/Linux bash config path conditionals (configure_shell.rs)
- Keep Unix-only PermissionsExt (required for cross-platform compilation)

All 233 tests pass on Unix. Tests will fail clearly on unsupported platforms.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Maximilian Roos <maximilian@Maximilians-MacBook-Pro.local>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-04 10:12:02 -08:00
Maximilian Roos 5875725619 feat: Remove wt push command
The `wt push` command is no longer available as a top-level command.
Its functionality has been moved under `wt dev push` to signify its use for development and testing workflows.
This change simplifies the user-facing API and emphasizes the core `merge` and `switch` commands.
2025-11-02 12:57:21 -08:00
Maximilian Roos 3d577e8f20 feat(git): Infer default branch when remote is absent
The `default_branch` method now includes a fallback to infer the default branch locally if no remote is configured or if querying the remote fails. This improves robustness for repositories without an `origin` remote, allowing commands like `merge`, `push`, `switch`, and `remove` to function without an immediate error.

The local inference process considers:
- The sole existing local branch.
- The current branch of the main worktree (HEAD).
- The `git config init.defaultBranch` setting.
- Common branch names like `main`, `master`, `develop`, and `trunk`.
2025-10-31 22:10:18 -07:00
Maximilian Roos 8a61ca7fb2 Fix: Rename unused variable in test_push_to_default_branch
The `_main_wt` variable was unused, causing a clippy warning. Renaming it to `main_wt` resolves this.

Co-authored-by: Claude <no-reply@anthropic.com>
2025-10-21 14:06:57 -07:00
Maximilian Roos 4873e38069 Refactor color detection and add error snapshots
Extract `should_use_color_with_env` to centralize color logic and improve testability. Add new integration test snapshots for various error conditions in `merge`, `push`, and `switch` commands to ensure consistent error message formatting.

Co-authored-by: Claude <no-reply@anthropic.com>
2025-10-20 13:34:42 -07:00
Maximilian Roos f1523cdb47 Refactor snapshot test helpers for reusability
Extract common `insta` settings and `Command` setup into shared functions. This reduces duplication across integration tests for `finish`, `merge`, `push`, and `switch` commands.

Co-authored-by: Claude <no-reply@anthropic.com>
2025-10-19 19:12:13 -07:00
Maximilian Roos 26da4cffa8 Implement push command to update target branches
Adds the `push` command, allowing users to push changes from their current worktree to a target branch. This command handles fast-forward checks, merge commit detection, and conflicts with dirty target worktrees.

New `git` utility functions are introduced:
- `is_ancestor`: Checks if one commit is an ancestor of another.
- `count_commits`: Counts commits between two references.
- `has_merge_commits`: Detects merge commits in a range.
- `get_changed_files`: Lists files changed between two references.

Co-authored-by: Claude <no-reply@anthropic.com>
2025-10-19 17:02:03 -07:00