mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
9ecd7945e0
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>