Files
max-sixty__worktrunk/docs/content/merge.md
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

9.3 KiB

+++ title = "wt merge" description = "Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree." weight = 13

[extra] group = "Commands" +++

Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree.

Unlike git merge, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch.

wt merge demo

Examples

Merge to the default branch:

{% terminal(cmd="wt merge") %} ◎ Running pre-merge project:test cargo nextest run Finished test profile [unoptimized + debuginfo] target(s) in 0.02s Summary [ 0.002s] 2 tests run: 2 passed, 0 skipped ◎ Merging 1 commit to main @ a1b2c3d (no commit/squash/rebase needed) * a1b2c3d feat: add hook registration hook.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) ✓ Merged to main (1 commit, 1 file, +31) ◎ Removing hooks worktree & branch in background (same commit as main, _) ○ Switched to worktree for main @ ~/repo {% end %}

Merge to a different branch:

{{ terminal(cmd="wt merge develop") }}

Keep the worktree after merging:

{{ terminal(cmd="wt merge --no-remove") }}

Preserve commit history (no squash):

{{ terminal(cmd="wt merge --no-squash") }}

Create a merge commit — rebased semi-linear history by default:

{{ terminal(cmd="wt merge --no-ff") }}

Skip committing/squashing (rebase still runs unless --no-rebase):

{{ terminal(cmd="wt merge --no-commit") }}

Preserve the exact clean commit graph and tip:

{{ terminal(cmd="wt merge --no-commit --no-rebase") }}

Pipeline

wt merge runs these steps:

  1. Commit — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in background. Skipped when squashing (the default) — changes are staged during the squash step instead. With --no-squash, this is the only commit step.
  2. Squash — Combines all commits since target into one (like GitHub's "Squash and merge"). Use --stage to control what gets staged: all (default), tracked, or none. Working-tree changes swept into the squash are backed up first to refs/wt-backup/<branch>. With --no-squash, individual commits are preserved.
  3. Rebase — Rebases onto target, skipping when nothing needs replaying (wt step rebase gives the conditions). A conflict stops the merge with the rebase left open in the worktree, to resolve or abort. With --no-rebase, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
  4. Pre-merge hooks — Hooks run after rebase, before merge. Failures abort. See wt hook.
  5. Merge — Fast-forward merge to the target branch (wt step push). With --no-ff, a merge commit is created instead — semi-linear history after the default rebase, while explicit --no-rebase preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected.
  6. Pre-remove hooks — Hooks run before removing worktree. Failures abort.
  7. Cleanup — Removes the worktree and branch. Use --no-remove to keep the worktree. When already on the target branch or in the primary worktree, the worktree is preserved.
  8. Post-remove + post-merge hooks — Run in background after cleanup.

Use --no-commit to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless --no-rebase is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with wt step commit. Requires a clean working tree.

wt merge targets the local default-branch ref and never fetches. When that ref lags its upstream — e.g. a primary checkout's main left behind origin/main — a branch based on the newer upstream tip is measured, squashed, and rebased against the upstream (so already-upstream commits are never folded into the squash), and the final fast-forward carries the local ref through the already-fetched upstream commits by their real SHAs. wt step squash and wt step rebase measure the same way. A local target that has diverged from its upstream — its own commits and behind — cannot fast-forward, so the merge is refused until the target is reconciled.

Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.

Historically, ensuring tests ran before merging was difficult to enforce locally. Remote CI was valuable for the process as much as the checks: it guaranteed validation happened. wt merge brings that guarantee local.

The full workflow: start an agent (one of many) on a task, work elsewhere, return when it's ready. Review the diff, run wt merge, move on. Pre-merge hooks validate before merging — if they pass, the branch goes to the default branch and the worktree cleans up.

[[pre-merge]]
test = "cargo test"
lint = "cargo clippy"

See also

  • wt step — Run individual operations (commit, squash, rebase, push)
  • wt remove — Remove worktrees without merging
  • wt switch — Navigate to other worktrees

Command reference

{% terminal() %} wt merge - Merge current branch into the target branch

Squash & rebase, fast-forward the target branch, remove the worktree.

Usage: wt merge [OPTIONS] [TARGET]

Arguments: [TARGET] Target branch

      Defaults to default branch.

Options: --no-squash Skip commit squashing

  <b><span class=c>--no-commit</span></b>
      Skip commit and squash

  <b><span class=c>--no-rebase</span></b>
      Skip rebase; require the target to fast-forward to the resulting tip

  <b><span class=c>--no-remove</span></b>
      Keep worktree after merge

  <b><span class=c>--no-ff</span></b>
      Create a merge commit (no fast-forward)

  <b><span class=c>--stage</span></b><span class=c> &lt;STAGE&gt;</span>
      What to stage before committing [default: all]

      Possible values:
      - <b><span class=c>all</span></b>:     Stage everything: untracked files + unstaged tracked changes
      - <b><span class=c>tracked</span></b>: Stage tracked changes only (like <b>git add -u</b>)
      - <b><span class=c>none</span></b>:    Stage nothing, commit only what&#39;s already in the index

-h, --help Print help (see a summary with '-h')

Automation: --no-hooks Skip hooks

  <b><span class=c>--format</span></b><span class=c> &lt;FORMAT&gt;</span>
      Output format

      JSON prints structured result to stdout after merge completes.

      [default: text]
      [possible values: text, json]

Global Options: -C <path> Working directory for this command

  <b><span class=c>--config</span></b><span class=c> &lt;path&gt;</span>
      User config file path

  <b><span class=c>--config-set</span></b><span class=c> &lt;toml&gt;</span>
      Override config with inline TOML, e.g. --config-set list.full=true (repeatable)

-v, --verbose... Verbose output (-v: info logs + hook/alias template variables on stderr; -vv: also debug logs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 to apply the same level everywhere — including shell completion, which no flag can reach

-y, --yes Skip approval prompts {% end %}