Files
Maximilian Roos 9ecd7945e0 fix(merge): measure squash/rebase against the target's upstream, not a stale local ref (#3549)
Fixes #3519.

## The bug

`wt merge` resolves its target to the **local** default-branch ref and
squashes/rebases the span `merge-base(local-target, HEAD)..HEAD`. When
the primary checkout's local `main` is behind `origin/main` **and** the
branch descends from the newer `origin/main` tip (e.g. created with
`--base origin/main`), that merge-base falls back at the stale local
tip, so the span sweeps in every commit already on `origin/main`. The
squash folds them into a single commit and the local fast-forward lands
it — corrupting the primary checkout's `main` and, if that ref is later
pushed, duplicating already-upstream content under new SHAs. Reproduced
deterministically before fixing: a 1-commit feature off `origin/main`,
with local `main` two commits behind, squashed "3 commits" into a new
SHA on local `main` — matching the report.

The same mis-measured span reaches every rewrite entry point: a
standalone `wt step squash` folds the upstream commits into the *branch*
(the reporter's 1-commit PR becoming 13), and `wt step rebase` replays
them onto the stale ref when it fires.

## The fix: measure against the upstream; let the merge carry

One local-only predicate (no fetch — worktrunk is local-first),
`Repository::span_upstream`: the target's upstream, returned exactly
when the branch's history extends past the local target ref into it —
i.e. `merge-base(HEAD, upstream)` is not an ancestor of the local
target. The rewrite pipeline responds by *measuring* there instead of
guessing from the stale ref:

- **`wt step squash`** (and the squash inside `wt merge`) computes its
span, commit count, message, and `reset --soft` base against the
upstream — only the branch's own commits fold, and the target ref is
never touched. The `--dry-run`/`--show-prompt` previews use the same
base.
- **`wt step rebase`** (and the rebase inside `wt merge`) rebases *onto*
the upstream, so a non-linear branch replays only its own commits
instead of duplicating upstream ones onto the stale ref. In the plain
stale topology it no-ops, as before.
- **`wt merge`** announces the situation up front, then completes: the
fast-forward carries local `main` through the already-fetched upstream
commits by their real SHAs to the result — the same graph a fresh local
`main` would have produced:

  ```
○ Local main is 2 commits behind origin/main; the merge fast-forwards
through them
  ◎ Squashing 2 commits into a single commit (2 files, +2)...
  ✓ Squashed @ 3f2a91c
  ✓ Merged to main (3 commits, 3 files, +3)
  ```

The one refusal left is forced by topology rather than chosen by policy:
a target that has **diverged** from its upstream (its own commits *and*
behind, with the branch based past it) can never fast-forward in any
mode — reconciling the target's own commits is the user's call, so the
merge stops before any rewrite or approval prompt:

```
✗ Local main has diverged from origin/main — can't fast-forward to a branch based on origin/main
↳ Reconcile main with origin/main (rebase or merge its local commits), or specify a different target
```

This is the reporter's suggested direction ("use `origin/<default>` as
the merge/rebase target"), minus the network: the already-fetched
remote-tracking ref is the measurement base, and `wt merge` still never
touches the wire. It also extends the rule the rest of worktrunk already
applies — `IntegrationTargets` / the preview panes' comparison base
treat the upstream as the truth when the local default lags it; the
merge pipeline was the one mutating surface that didn't.

Quiet on everything that must keep working: a legitimately-diverged
local target with the branch forked from the shared base (the supported
offline workflow — existing regression test unchanged), targets with no
upstream, orphan branches, and explicit non-branch targets like `wt step
squash origin/main`.

## Tests

- `test_merge_carries_stale_local_main_through_upstream` — the #3519
topology end-to-end: merge succeeds, the carry is announced, the squash
folds only the branch's own commits and sits directly on the
`origin/main` tip, and `origin/main` is an ancestor of the merged `main`
(real SHAs, no duplication).
- `test_step_squash_measures_against_upstream_when_local_main_stale` —
the standalone squash folds only the branch's own commits and never
moves the target ref (this scenario previously corrupted `main` through
a squash-then-merge sequence).
- `test_step_rebase_targets_upstream_when_local_main_stale` — a branch
that merged `origin/main` in rebases onto the upstream: linearized, real
upstream SHAs retained, target ref untouched.
- `test_merge_refuses_diverged_target_when_branch_based_on_upstream` —
the forced refusal: nothing mutated, branch survives.
- `test_merge_removes_branch_when_local_main_diverged_from_upstream`
(legitimate divergence, branch from shared base) still passes — no
regression.

Docs: the merge help page (primary source in `src/cli/mod.rs`) describes
the upstream-aware measurement, the carry, and the diverged refusal;
generated mirrors and help snapshots regenerated.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:34:16 -07:00
..