Comment by akersten

16 hours ago

Does it fix the current UX issue with Squash & Merge?

Right now I manually do "stacked PRs" like this:

main <- PR A <- PR B (PR B's merge target branch is PR A) <- PR C, etc.

If PR B merges first, PR A can merge to main no problems. If PR A merges to main first, fixing PR B is a nightmare. The GitHub UI automatically changes the "target" branch of the PR to main, but instantly conflicts spawn from nowhere. Try to rebase it and you're going to be manually looking at every non-conflicting change that ever happened on that branch, for no apparent reason (yes, the reason is that PR A merging to main created a new merge commit at the head of main, and git just can't handle that or whatever).

So I don't really need a new UI for this, I need the tool to Just Work in a way that makes sense to anyone who wasn't Linus in 1998 when the gospel of rebase was delivered from On High to us unwashed Gentry through his fingertips..

Yes, we handle this both in the CLI and server using git rebase --onto

  git rebase --onto <new_commit_sha_generated_by_squash> <original_commit_sha_from_tip_of_merged_branch> <branch_name>

So for ex in this scenario:

  PR1: main <- A, B              (branch1)
  PR2: main <- A, B, C, D        (branch2)
  PR3: main <- A, B, C, D, E, F  (branch3)

When PR 1 and 2 are squash merged, main now looks like:

  S1 (squash of A+B), S2 (squash of C+D)

Then we run the following:

  git rebase --onto S2 D branch3

Which rewrites branch3 to:

  S1, S2, E, F

This operation moves the unique commits from the unmerged branch and replays them on top of the newly squashed commits on the base branch, avoiding any merge conflicts.

  • That’s how I’ve been working for years now. Does anyone know how this gh stacks work internally? Does it do the same thing under the hood?

    I’m conflicted about it, seems like a good convenience, but I wouldn’t want my team to get dependent on an exclusive feature of a single provider

I made a tool that adresses this precise problem: https://github.com/scortexio/autorestack-action/

It does some merge magic so that PR B shows the correct diff; and does so without needing to force push, so on your side you can just "git pull" and continue working.

Of course I expect this repo to become obsolete when GitHub makes their native stacking public.

Conflicts spawn most likely because PR A was squashed, and once you squash Git doesn't know that PR B's ancestors commits are the same thing as the squashed commit on main.

No idea if this feature fixes this.

Edit: Hopefully `gh stack sync` does the rebasing correctly (rebase --onto with the PR A's last commit as base)

  • > Conflicts spawn most likely because PR A was squashed, and once you squash Git doesn't know that PR B's ancestors commits are the same thing as the squashed commit on main.

    Yeah, and I kind of see how git gets confused because the squashed commits essentially disappear. But I don't know why the rebase can't be smart when it sees that file content between the eventual destination commit (the squash) is the same as the tip of the branch (instead of rebasing one commit at a time).

    • Because at first your have this

        main <- PR A <- PR B
      

      Then you'll have

        main, squashed A
            \
             \-> PR A -> PR B
      

      The tip of B is the list of changes of both A and B, while the tip of main is now the squashed version of the changes of A. Unless a branch tracks the end of A in the PR B, It looks like more you want to apply A and B on top of A again.

      A quick analogy to math

        main is X
        A is 3
        B is 5
      

      Before you have X + 3 + 5 which was equivalent to X + 8, but then when you squash A on on X, it looks like (X + 3) + (3 + 5) from `main`'s point of view, while from B, it should be X + (3 + 5). So you need to rebase B to remove its 3 so that it can be (X + 3) + 5.

      Branches only store the commits at the top. The rest is found using the parent metadata in each commits (a linked list. Squashing A does not remove its commits. It creates a new one, and the tip of `main` as its parent and set the new commit as the tip of `main`. But the list of commits in B still refer to the old tip of `main` as their ancestor and still includes the old commits of A. Which is why you can't merge the PR because it would have applies the commits of A twice.

I agree that this is annoying and unintuitive. But I don’t see the simplest solution here, so:

All you need to do is pull main, then do an interactive rebase with the next branch in your stack with ‘git rebase -i main’, then drop all the commits that are from the branch you just merged.

  • I typically prefix my commit messages with the ticket number to make it easier to spot the commits to drop.

I'm not sure I follow your workflow exactly. If PR B is merged, then I'd expect PR A to already be merged (I'd normally branch off of A to make B.)

That said, after the squash merge of A and git fetch origin, you want something like git rebase --update-refs --onto origin/main A C (or whatever the tip of the chain of branches is)

The --update-refs will make sure pr B is in the right spot. Of course, you need to (force) push the updated branches. AFAICT the gh command line tool makes this a bit smoother.

If I'm following correctly, the conflicts arise from other commits made to main already - you've implicitly caught branch A up to main, and now you need catch branch B up to main, for a clean merge.

I don't see how there is any other way to achieve this cleanly, it's not a git thing, it's a logic thing right?

  • I've no issue with the logic of needing to update feature branches before merging, that's pretty bread and butter. The specific issue with this workflow is that the "update branch" button for PR B is grayed out because there are these hallucinated conflicts due to the new squash commit.

    The update branch button works normally when I don't stack the PRs, so I don't know. It just feels like a half baked feature that GitHub automatically changes the PR target branch in this scenario but doesn't automatically do whatever it takes for a 'git merge origin/main' to work.

    • > the "update branch" button for PR B is grayed out because there are these hallucinated conflicts due to the new squash commit

      Those are not hallucinated. PR B still contains all the old commits of A which means merging would apply them twice. The changes in PR B are computed according to the oldest commits belonging to PR B and main which is the parent of squashed A. That would essentially means applying A twice which is not good.

      As for updating PR B, PR B doesn't know where PR A (that are also in PR B) ends because PR A is not in main. Squashed A is a new commit and its diff corresponds to the diff of a range of commits in PR B (the old commits of PR A), not the whole B. There's a lot of metadata you'd need to store to be able to update PR B.

      2 replies →

You "just" need to know the original merge-base of PR B to fix this. github support is not really required for that. To me that's the least valuable part of support for stacked PRs since that is already doable yourself.

The github UI may change the target to main but your local working branch doesn't, and that's where you `rebase --onto` to fix it, before push to origin.

It's appropriate for github to automatically change the target branch, because you want the diff in the ui to be representative. IIRC gitlab does a much better job of this but this is already achievable.

What is actually useful with natively supported stacks is if you can land the entire stack together and only do 1 CI/actions run. I didn't read the announcement to see if it does that. You typically can't do that even if you merge PR B,C,D first because each merge would normally trigger CI.

EDIT: i see from another comment (apparently from a github person) that the feature does in fact let you land the entire stack and only needs 1 CI run. wunderbar!

Oh that's annoying, seems to me there wouldn't have been an issue if you just merged B into A after merging A into main, or the other way around but that already works fine as you pointed out.

I mean if you've got a feature set to merge into dev, and it suddenly merges into main after someone merged dev into main then that's very annoying.