mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
b17a1364f1
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>
176 lines
9.3 KiB
Markdown
176 lines
9.3 KiB
Markdown
+++
|
|
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"
|
|
+++
|
|
|
|
<!-- ⚠️ AUTO-GENERATED from `wt merge --help-page` — edit src/cli/mod.rs to update -->
|
|
|
|
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.
|
|
|
|
<figure class="demo">
|
|
<picture>
|
|
<source srcset="/assets/docs/dark/wt-merge.gif" media="(prefers-color-scheme: dark)">
|
|
<img src="/assets/docs/light/wt-merge.gif" alt="wt merge demo" width="1600" height="900">
|
|
</picture>
|
|
</figure>
|
|
|
|
## Examples
|
|
|
|
Merge to the default branch:
|
|
|
|
{% terminal(cmd="wt merge") %}
|
|
<span class=c>◎</span> <span class=c>Running pre-merge <b>project:test</b></span>
|
|
<span style='background:var(--bright-white,#fff)'> </span> <span class=d><span style='color:var(--blue,#00a)'>cargo</span></span><span class=d> nextest run</span>
|
|
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
|
|
Summary [ 0.002s] 2 tests run: 2 passed, 0 skipped
|
|
<span class=c>◎</span> <span class=c>Merging 1 commit to <b>main</b> @ <span class=d>a1b2c3d</span> (no commit/squash/rebase needed)</span>
|
|
<span style='background:var(--bright-white,#fff)'> </span> * <span style='color:var(--yellow,#a60)'>a1b2c3d</span> feat: add hook registration
|
|
<span style='background:var(--bright-white,#fff)'> </span> hook.rs | 31 <span class=g>+++++++++++++++++++++++++++++++</span>
|
|
<span style='background:var(--bright-white,#fff)'> </span> 1 file changed, 31 insertions(+)
|
|
<span class=g>✓</span> <span class=g>Merged to <b>main</b> <span style='color:var(--bright-black,#555)'>(1 commit, 1 file, <span class=g>+31</span></span></span><span style='color:var(--bright-black,#555)'>)</span>
|
|
<span class=c>◎</span> <span class=c>Removing <b>hooks</b> worktree & branch in background (same commit as <b>main</b>,</span> <span class=d>_</span><span class=c>)</span>
|
|
<span class=d>○</span> Switched to worktree for <b>main</b> @ <b>~/repo</b>
|
|
{% 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`](@/step.md#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`](@/hook.md).
|
|
5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](@/step.md#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.
|
|
|
|
```toml
|
|
[[pre-merge]]
|
|
test = "cargo test"
|
|
lint = "cargo clippy"
|
|
```
|
|
|
|
## See also
|
|
|
|
- [`wt step`](@/step.md) — Run individual operations (commit, squash, rebase, push)
|
|
- [`wt remove`](@/remove.md) — Remove worktrees without merging
|
|
- [`wt switch`](@/switch.md) — 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: <b><span class=c>wt merge</span></b> <span class=c>[OPTIONS]</span> <span class=c>[TARGET]</span>
|
|
|
|
<b><span class=g>Arguments:</span></b>
|
|
<span class=c>[TARGET]</span>
|
|
Target branch
|
|
|
|
Defaults to default branch.
|
|
|
|
<b><span class=g>Options:</span></b>
|
|
<b><span class=c>--no-squash</span></b>
|
|
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> <STAGE></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's already in the index
|
|
|
|
<b><span class=c>-h</span></b>, <b><span class=c>--help</span></b>
|
|
Print help (see a summary with '-h')
|
|
|
|
<b><span class=g>Automation:</span></b>
|
|
<b><span class=c>--no-hooks</span></b>
|
|
Skip hooks
|
|
|
|
<b><span class=c>--format</span></b><span class=c> <FORMAT></span>
|
|
Output format
|
|
|
|
JSON prints structured result to stdout after merge completes.
|
|
|
|
[default: text]
|
|
[possible values: text, json]
|
|
|
|
<b><span class=g>Global Options:</span></b>
|
|
<b><span class=c>-C</span></b><span class=c> <path></span>
|
|
Working directory for this command
|
|
|
|
<b><span class=c>--config</span></b><span class=c> <path></span>
|
|
User config file path
|
|
|
|
<b><span class=c>--config-set</span></b><span class=c> <toml></span>
|
|
Override config with inline TOML, e.g. --config-set list.full=true (repeatable)
|
|
|
|
<b><span class=c>-v</span></b>, <b><span class=c>--verbose</span></b><span class=c>...</span>
|
|
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
|
|
|
|
<b><span class=c>-y</span></b>, <b><span class=c>--yes</span></b>
|
|
Skip approval prompts
|
|
{% end %}
|
|
|
|
<!-- END AUTO-GENERATED -->
|