Repository navigation
Keep remote merge commits intact in but branch update - #16312
Merged
Merged
Conversation
juanibiapina
marked this pull request as ready for review
October 13, 2026 16:21
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The old-tip selector can be rewritten, leaving the output graph inconsistent with immutable descendant commits.
1 open finding
What changed in this PR
Preserves merge topology and merged content when but branch update rebuilds branches.
Changes:
- Retains side parents while replacing first-parent edges.
- Keeps external commits attached to the old branch tip.
- Adds CLI and workspace-level merge integration tests.
| File | Description |
|---|---|
crates/but/tests/fixtures/scenario/branch-integrate-remote-merge.sh |
Adds a remote-merge CLI fixture. |
crates/but/tests/but/command/branch/update.rs |
Tests merged-file checkout and exact fast-forwarding. |
crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs |
Tests remote and local merge preservation. |
crates/but-workspace/tests/fixtures/scenario/remote-diverged-by-merge-with-workspace.sh |
Models a diverged remote merge. |
crates/but-workspace/tests/fixtures/scenario/remote-advanced-by-merge-with-workspace.sh |
Models a branch behind a remote merge. |
crates/but-workspace/tests/fixtures/scenario/local-merge-with-remote-advanced-with-workspace.sh |
Models rebasing a local merge. |
crates/but-workspace/src/graph_manipulation.rs |
Restores captured parent ordering. |
crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs |
Replaces only first-parent edges. |
crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs |
Reconnects external descendants during integration. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+446
to
+449
| for (parent, order) in editor.direct_parents(existing)? { | ||
| if order == 0 { | ||
| editor.remove_edges(existing, parent)?; | ||
| } |
Contributor
Author
There was a problem hiding this comment.
Known limitation: replaying a merge whose parents conflict is not supported by the rebase yet, so this case now fails instead of silently dropping the side branch. It is described in the PR under "Known limitation" and will be fixed separately.
When origin/<branch> advanced by a merge commit, `but branch update` replaced it with an empty one-parent copy: the merged files never arrived and every rerun stacked another copy. Rebuilding the branch lost the merge's side parent, and the rebuild reconnected the base after the parents a commit kept, which swapped a merge's parents. Reused commits now drop only their first-parent edge, and the base is reconnected in the first-parent slot. Commits outside the workspace that hang off the branch reference stay on the old tip, since keeping the side parent would otherwise make them follow the moved reference and form a cycle. A branch that is only behind now lands on the remote merge itself, and a rebuilt local merge keeps its side branch. Fixes #16235
The fix keeps merges intact in several shapes the first tests did not reach, each broken before it: a forge merging the target into the branch, an octopus merge (which failed with a graph cycle), a remote merge next to another applied stack, and the merge strategy on top of a local merge.
A collaborator merging a newer main into a remote branch is a common way to hit #16235. The test records what `but status` shows before and after `but branch update` in that case: the remote history arrives as is, including the newer main's changes, while the workspace base stays put.
Pulling the newer main after the update moves the base under the branch; the test now records that final state, where the merge keeps merging the newer main.
When origin/A merges another applied branch whose local copy is behind, updating A brings that branch's newer commits in through the merge and leaves the local branch untouched until it is updated itself.
The status checks verify the commits and their state, not colors, so inline text snapshots say the same and are readable in review.
The story test now has a variant where the branch also carries a commit that was never pushed. Updating keeps the remote history as is and rebases the local commit on top, and a later pull keeps both.
juanibiapina
force-pushed
the
fix-branch-update-remote-merge
branch
from
October 14, 2026 11:14
8420465 to
66ef248
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

🧢 Changes
but branch updateprinted Updated branch A. but replaced a merge from the remote with an empty copy, so the merged changes never arrived. It now keeps remote merges as they are: a branch that is only behind lands exactly onorigin/<branch>, and local commits are rebased on top of the unchanged merge.The story
You work on stacks
AandB, based onmainatM. On GitHub, a collaborator merges a newermainintoAand addsA2on top. You runbut branch update A.flowchart TB subgraph remote["On GitHub: origin/A"] direction LR rM("M") --> rA1("A1") --> rMerge("Merge<br/>newer main") --> rA2("A2") rM --> rm2("main2") --> rm3("main3") --> rMerge end subgraph old["Old code: A after update"] direction LR oM("M") --> oA1("A1") --> oMerge("Merge copy<br/>1 parent, empty") --> oA2("A2 copy") oM -.-> om2("main2<br/>not in A") -.-> om3("main3<br/>not in A") end subgraph fix["With the fix: A after update"] direction LR fM("M") --> fA1("A1") --> fMerge("Merge<br/>newer main") --> fA2("A2") fM --> fm2("main2") --> fm3("main3") --> fMerge end remote ~~~ old ~~~ fix style remote fill:#ffffff,stroke:#d0d7de style old fill:#ffffff,stroke:#d0d7de style fix fill:#ffffff,stroke:#d0d7de classDef base fill:#f6f8fa,stroke:#8c959f,color:#24292f classDef lost fill:#ffebe9,stroke:#cf222e,color:#82071e classDef kept fill:#dafbe1,stroke:#1a7f37,color:#116329 classDef missing fill:#ffffff,stroke:#cf222e,stroke-dasharray:4 3,color:#82071e class rM,rA1,rMerge,rA2,rm2,rm3,oM,oA1,fM,fA1,fm2,fm3,fA2 base class oMerge,oA2 lost class om2,om3 missing class fMerge keptBefore the update, the merge and the newer
mainexist only onorigin/A:After the update with the old code:
main2andmain3are still listed as missing fromA, and their files are not in the worktree.Aholds rewritten copies (◐); the merge copy has no changes.Aneeds a force push that replaces the collaborator's merge on GitHub. Each rerun adds another empty copy.After the update with the fix:
Aisorigin/A, with nothing pending.main2andmain3arrive through the merge.Muntilbut pull.Then
but pullPulling the newer
mainmoves the base fromMtomain3and rebasesAandBonto it.main's changes now come through the base, so the merge inAhas no changes left, andAneeds a force push. Both are existingbut pullbehaviour. The difference is the merge: the old code carries the one-parent empty copy, while with the fix it stays a merge ofmain3.flowchart TB subgraph old["Old code: after but pull"] direction LR oM("M") --> om2("main2") --> om3("main3<br/>new base") --> oA1("A1'") --> oMerge("Merge copy<br/>1 parent, empty") --> oA2("A2'") om3 --> oB1("B1'") end subgraph fix["With the fix: after but pull"] direction LR fM("M") --> fm2("main2") --> fm3("main3<br/>new base") --> fA1("A1'") --> fMerge("Merge<br/>A1' + main3, no changes") --> fA2("A2'") fm3 --> fMerge fm3 --> fB1("B1'") end old ~~~ fix style old fill:#ffffff,stroke:#d0d7de style fix fill:#ffffff,stroke:#d0d7de classDef base fill:#f6f8fa,stroke:#8c959f,color:#24292f classDef lost fill:#ffebe9,stroke:#cf222e,color:#82071e classDef kept fill:#dafbe1,stroke:#1a7f37,color:#116329 class oM,om2,om3,oA1,oA2,oB1,fM,fm2,fm3,fA1,fA2,fB1 base class oMerge lost class fMerge keptbut statusafter the pull is the same for old code and fix:When the merge brings in another applied branch
If
origin/Amerged another branchCthat you also have applied, andorigin/Chas commits your localClacks, updatingAbrings those commits in through the merge. Your localCstays as it was until you runbut branch update C.One thing to know: once
AcontainsC's newer commits,but statusno longer lists them as upstream forC, because they are already in the workspace throughA. So it stops showing thatCis behindorigin/C. Runbut branch update Cbefore pushingC; otherwise the push would have to overwrite those commits on GitHub.Known limitation
Rebuilding a local merge whose side branch conflicts with the new upstream commits now fails:
but branch updatestops with Failed to merge bases while cherry picking commit … and changes nothing. Before, it succeeded by flattening the merge and dropping the side branch's changes.The rebase can't yet replay a merge whose parents conflict. That limit already affects local merges with a hand-resolved conflict on
master, and lifting it is a separate change. Until then,but branch update <branch> --strategy mergekeeps the local merge and merges the remote on top, marking any conflict forbut resolve.Other remote histories
AA(the issue)origin/Aintegrate_upstream_merge_commit_when_remote_is_ahead_of_local, CLIintegrate_remote_merge_brings_its_merged_files_into_the_worktreemainmerged ontoA, then a commit (above)integrate_remote_branch_that_merged_a_newer_targetmainmerged intoA(forge's Update branch)main's changes missingintegrate_upstream_merge_of_target_when_remote_is_ahead_of_localintegrate_upstream_octopus_merge_when_remote_is_ahead_of_localBuntouchedintegrate_upstream_merge_commit_next_to_another_stackintegrate_upstream_merge_of_another_stack_that_is_behind_locallymainmerged ontoA, then a commit, with an unpushed local commit onAintegrate_remote_branch_that_merged_a_newer_target_below_a_local_commitAintegrate_upstream_merge_commit_below_local_commitintegrate_upstream_commit_below_rebuilt_local_mergeintegrate_with_merge_strategy_keeps_local_merge_below_the_upstream_merge☕️ Reasoning
A branch update should give you what is on the remote branch and bring it into your workspace. The branches you see on GitHub should be the branches you have locally; anything else is a surprise.
That holds regardless of the workspace base. When the remote branch merged a newer
main, the updated branch carries commits from amainlater than your base, so it is ahead of the base. That is expected: the branch you updated is now correct, andbut pulllater moves the base and rebases the branch onto it, as shown above.For review:
git pull --rebasewould drop the merge and replay its commits instead; please confirm keeping it is what we want.mergestep at the bottom of the chain now has the previous commit as its first parent, matching the step elsewhere. One existing snapshot changes for this.🎫 Affected issues
Fixes: #16235