Skip to content

fix(merge): Accept merge-shaped fast-forward histories - #3509

Merged
max-sixty merged 1 commit into
max-sixty:mainfrom
reneleonhardt:fix/merge-shaped-fast-forward
Jul 18, 2026
Merged

fix(merge): Accept merge-shaped fast-forward histories#3509
max-sixty merged 1 commit into
max-sixty:mainfrom
reneleonhardt:fix/merge-shaped-fast-forward

Conversation

@reneleonhardt

Copy link
Copy Markdown
Contributor

🧢 Changes

  • Accept preserved source graphs when the target is already an ancestor.
  • Retain divergence and unrelated-history rejection before publication.
  • Preserve source history for --no-commit --no-rebase and --no-ff --no-rebase.
  • Restore target autostashes and skip cleanup after late ancestry failures.

☕️ Reasoning

--no-rebase used a linear-history predicate that rejected any source range
containing a merge commit. Git can safely fast-forward to such a tip when the
target is already its ancestor.

This blocked real-world agentic coding integration: independently assembled
history could not be promoted without either rewriting the graph or using a raw
Git fallback.

🧪 Reproduction

From a clean source branch containing an internal merge, with main as an
ancestor:

wt merge main --no-commit --no-rebase --no-remove --no-hooks

Before this fix:

✗ Branch not rebased onto main
↳ To rebase first, run wt step rebase main; or remove --no-rebase

✅ Verification

  • Focused merge-shaped fast-forward tests pass.
  • Divergence, unrelated-history, hook-race, and autostash regressions pass.
  • Exact source SHA and parent graph are preserved.
  • CLI help snapshots and generated documentation are synchronized.
  • Combined publication-set pre-merge hook: 4,519 tests passed on macOS.

Developed with carefully directed, manually reviewed AI assistance.

Co-Authored-By: GPT-5.6 Sol codex@openai.com

Recognize source tips that already contain the target as exact-shape merge candidates while retaining divergence checks and autostash restoration.

Co-Authored-By: GPT-5.6 Sol <codex@openai.com>
@max-sixty

Copy link
Copy Markdown
Owner

@worktrunk-bot what does this PR do? Why is it an improvement?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

This PR loosens the --no-rebase guard in wt merge so it accepts a source branch whose history contains an internal merge commit, as long as the target can still fast-forward to the source's tip.

What changed. wt merge ... --no-rebase skips the rebase step and then verifies the graph is already integratable. The old check used is_rebased_onto, which required linear history — it rejected the range if git rev-list --merges target..HEAD was non-empty (repository_ext.rs). The new check drops that linearity requirement and asks only the question that actually matters for a fast-forward: is the target an ancestor of the source tip? (merge.rs, via is_ancestor_by_sha).

Why it's an improvement. The linearity check conflated two independent properties. A merge commit in target..HEAD doesn't mean the source is unintegrated — if main is already an ancestor of the source tip, git fast-forwards cleanly and the source SHA and its parent graph land on the target unchanged. The old predicate rejected that case anyway, forcing users to either rewrite the graph or drop to raw git. The concrete blocked flow (from the PR description):

wt merge main --no-commit --no-rebase --no-remove --no-hooks
# ✗ Branch not rebased onto main   ← even though main is an ancestor

The two properties that should block the merge are retained, because they both make the target a non-ancestor: divergence (the target advanced past the merge-base) and unrelated history (no merge-base at all) still fail with NotRebased. So the change is narrowly scoped to "merge-shaped but fast-forwardable" — exactly the graphs where preserving history is safe.

The main use case is promoting independently-assembled history (e.g. agentic coding runs that produce internal merges) without flattening it.

Verification notes
  • The retained rejections are structural: is_ancestor_by_sha(target, source) is false whenever merge_base(target, source) != target (divergence) or the merge-base doesn't exist (unrelated history), so both old failure modes still return GitError::NotRebased.
  • test_merge_no_commit_no_rebase_preserves_merge_graph asserts the exact source SHA and rev-list --parents graph survive the merge; test_merge_no_commit_with_dirty_tree gains assertions that a late failure restores the autostash and skips cleanup.
  • The removed is_rebased_onto call was the only remaining use of RepositoryCliExt in merge.rs, so the import is dropped too.

@max-sixty

Copy link
Copy Markdown
Owner

how does it compare to git's handling of similar flags, for example in pull?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Good question — the change moves wt merge --no-rebase closer to git's semantics, while a deeper design difference remains.

Where it now matches git. Git's fast-forward decision (git merge --ff-only, git pull --ff-only, and the --ff default when it can fast-forward) is a single question: is the current tip an ancestor of the incoming tip? Git never inspects the shape of the incoming range — a fast-forward just advances a ref, so merge commits inside the range are irrelevant. The old is_rebased_onto predicate added a linearity requirement (rev-list --merges target..HEAD must be empty) that git has no equivalent of — it was strictly stricter than --ff-only. This PR drops it in favor of the bare ancestor test (merge.rs#L347), so --no-rebase now accepts exactly the graphs git merge --ff-only would.

The mapping.

worktrunk git analog
wt merge (default: rebase, then ff) git pull --rebase — reland linear, then fast-forward
wt merge --no-rebase git merge --ff-only — accept iff target is an ancestor of the source tip
wt merge --no-ff git merge --no-ff — record a merge commit

Where it deliberately stays stricter than git. The ancestor check sits above the --no-ff branch, so it also gates wt merge --no-rebase --no-ff. Plain git merge --no-ff will happily reconcile two divergent branches by computing a real 3-way merge. Worktrunk won't: divergence (target advanced past the merge-base) and unrelated history both still fail with NotRebased, because worktrunk's model is "the source already contains the target's work" — reconciling divergence is the job of the rebase step (the default), not a merge commit. So --no-ff here means "fast-forwardable, but record a merge commit for it" rather than git's "resolve arbitrary divergence."

Put differently: git's pull reconciles divergence by merging; worktrunk reconciles it by rebasing, and --no-rebase is an assertion that no reconciliation is needed (the target is already an ancestor). This PR fixes the case where that assertion held but the old linearity check rejected it anyway.

@max-sixty

Copy link
Copy Markdown
Owner

does it do a rebase when --no-rebase is passed/

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

No — --no-rebase skips the rebase step entirely.

The rebase flag is false, so the handle_rebase(...) call in the if rebase branch never runs. Instead the else branch does a single read-only ancestor check: resolve target and HEAD to SHAs and verify is_ancestor_by_sha(target, HEAD). If the target isn't an ancestor of the source tip, it bails with NotRebased; it never rewrites commits (merge.rs#L327-L351).

So the graph produced by the earlier commit/squash stages is preserved verbatim — the check only asserts it's already fast-forwardable. This matches the step-3 docs: "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." (cli/mod.rs#L1402).

(One nuance: the earlier commit/squash stages can still rewrite commits — --no-commit disables that. --no-rebase only turns off the rebase step.)

@max-sixty
max-sixty merged commit 79be81a into max-sixty:main Jul 18, 2026
39 checks passed
@max-sixty max-sixty mentioned this pull request Jul 23, 2026
max-sixty added a commit that referenced this pull request Jul 24, 2026
Highlights: clickable CI and dev-server-URL statusline segments in Claude Code (#3550), `wt merge --no-rebase` accepting merge-shaped fast-forward histories (#3509), a faster and lower-CPU `wt switch` picker (#3544, #3534), and SignPath code signing for the Windows release binary (#3553). Full details in CHANGELOG.md.

> _This was written by Claude Code on behalf of max_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants