Skip to content

Keep remote merge commits intact in but branch update - #16312

Merged
juanibiapina merged 7 commits into
masterfrom
fix-branch-update-remote-merge
Oct 15, 2026
Merged

juanibiapina merged 7 commits into
masterfrom
fix-branch-update-remote-merge

Conversation

@juanibiapina

@juanibiapina juanibiapina commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

🧢 Changes

but branch update printed 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 on origin/<branch>, and local commits are rebased on top of the unchanged merge.

The story

You work on stacks A and B, based on main at M. On GitHub, a collaborator merges a newer main into A and adds A2 on top. You run but 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 kept
Loading

Before the update, the merge and the newer main exist only on origin/A:

but status before the update

After the update with the old code:

but status after the update with the old code

  • main2 and main3 are still listed as missing from A, and their files are not in the worktree.
  • A holds rewritten copies (◐); the merge copy has no changes.
  • Pushing A needs a force push that replaces the collaborator's merge on GitHub. Each rerun adds another empty copy.

After the update with the fix:

but status after the update with the fix

  • A is origin/A, with nothing pending.
  • main2 and main3 arrive through the merge.
  • The workspace base stays at M until but pull.

Then but pull

Pulling the newer main moves the base from M to main3 and rebases A and B onto it. main's changes now come through the base, so the merge in A has no changes left, and A needs a force push. Both are existing but pull behaviour. The difference is the merge: the old code carries the one-parent empty copy, while with the fix it stays a merge of main3.

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 kept
Loading

but status after the pull is the same for old code and fix:

but status after but pull

When the merge brings in another applied branch

If origin/A merged another branch C that you also have applied, and origin/C has commits your local C lacks, updating A brings those commits in through the merge. Your local C stays as it was until you run but branch update C.

One thing to know: once A contains C's newer commits, but status no longer lists them as upstream for C, because they are already in the workspace through A. So it stops showing that C is behind origin/C. Run but branch update C before pushing C; 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 update stops 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 merge keeps the local merge and merges the remote on top, marking any conflict for but resolve.

Other remote histories
Remote history of A Old code With the fix Test
Merge on top of A (the issue) ❌ empty copy, merged files missing ✅ lands on origin/A integrate_upstream_merge_commit_when_remote_is_ahead_of_local, CLI integrate_remote_merge_brings_its_merged_files_into_the_worktree
Newer main merged onto A, then a commit (above) ❌ empty copy ✅ CLI integrate_remote_branch_that_merged_a_newer_target
main merged into A (forge's Update branch) ❌ main's changes missing ✅ integrate_upstream_merge_of_target_when_remote_is_ahead_of_local
Octopus merge ❌ error Add edge introduces a cycle ✅ integrate_upstream_octopus_merge_when_remote_is_ahead_of_local
Remote merge, another stack applied ❌ merged file missing ✅ B untouched integrate_upstream_merge_commit_next_to_another_stack
Remote merge of another applied branch whose local copy is behind ❌ empty copy ✅ its newer commits arrive through the merge, local branch untouched integrate_upstream_merge_of_another_stack_that_is_behind_locally
Newer main merged onto A, then a commit, with an unpushed local commit on A ❌ empty copy ✅ remote history kept, local commit rebased on top CLI integrate_remote_branch_that_merged_a_newer_target_below_a_local_commit
Remote merge, local commit on A ❌ merge rebuilt with swapped parents ✅ merge kept, local commit on top integrate_upstream_merge_commit_below_local_commit
Local merge, remote commit (pull-rebase) ❌ side branch's changes lost ✅ merge kept integrate_upstream_commit_below_rebuilt_local_merge
Local merge, Merge strategy ❌ side branch's changes lost ✅ integrate_with_merge_strategy_keeps_local_merge_below_the_upstream_merge
A commit first, then a merge on top of it ✅ already worked ✅

☕️ 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 a main later than your base, so it is ahead of the base. That is expected: the branch you updated is now correct, and but pull later moves the base and rebases the branch onto it, as shown above.

For review:

  • A rebuilt local merge now keeps its side branch. git pull --rebase would drop the merge and replay its commits instead; please confirm keeping it is what we want.
  • A synthetic merge step 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

@juanibiapina
juanibiapina requested a review from krlvi October 13, 2026 16:16
@github-actions github-actions Bot added rust Pull requests that update Rust code CLI The command-line program `but` labels Oct 13, 2026
@juanibiapina
juanibiapina requested a balanced review from Copilot October 13, 2026 16:18
@juanibiapina
juanibiapina marked this pull request as ready for review October 13, 2026 16:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 thread crates/but-workspace/src/branch/integrate_branch_upstream/mod.rs
Copilot AI balanced review requested due to automatic review settings October 13, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused graph changes are supported by comprehensive topology and worktree tests.

1 open finding

🧠 Review effort: Balanced

@Caleb-T-Owens Caleb-T-Owens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copilot AI balanced review requested due to automatic review settings October 14, 2026 09:41
juanibiapina added a commit that referenced this pull request Oct 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reparented merges can still skip upstream integration on partial graphs or fail when their retained parents conflict.

3 open findings

🧠 Review effort: Balanced

Comment on lines +446 to +449
for (parent, order) in editor.direct_parents(existing)? {
if order == 0 {
editor.remove_edges(existing, parent)?;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs
Copilot AI balanced review requested due to automatic review settings October 14, 2026 09:49
juanibiapina added a commit that referenced this pull request Oct 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A fast-forward merge containing the local tip as a non-first parent is still rewritten and loses its original topology.

4 open findings

🧠 Review effort: Balanced

Comment thread crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs
Copilot AI balanced review requested due to automatic review settings October 14, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The graph-parent rewiring deliberately changes local-merge semantics and warrants the requested human confirmation.

4 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 14, 2026 10:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Core Git graph rewriting and the intentional preservation of rebuilt local merges require final human validation.

4 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 14, 2026 11:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Commit-graph rewriting and merge-parent preservation warrant final human validation despite broad regression coverage.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

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
juanibiapina force-pushed the fix-branch-update-remote-merge branch from 8420465 to 66ef248 Compare October 14, 2026 11:14
@juanibiapina
juanibiapina merged commit 8579195 into master Oct 15, 2026
49 checks passed
@juanibiapina
juanibiapina deleted the fix-branch-update-remote-merge branch October 15, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLI The command-line program `but` rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

but branch update reports success but never integrates a remote merge commit (empty copies, no convergence)

3 participants